init: DigOpsCC UI - React frontend for call-center backend (migrated from /opt untracked source)
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
*.log
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_BASE_PATH=/
|
||||||
|
ENV VITE_BASE_PATH=${VITE_BASE_PATH}
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.29-alpine
|
||||||
|
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# DigOpsCC UI
|
||||||
|
|
||||||
|
Новый React UI для существующего `call-center` backend.
|
||||||
|
|
||||||
|
## Backend contract
|
||||||
|
|
||||||
|
UI использует тот же API Gateway, что и `call-center/ui`:
|
||||||
|
|
||||||
|
- gateway health: `/health`
|
||||||
|
- service proxy: `/proxy/{service}/{path}`
|
||||||
|
- auth header: `Authorization: Bearer <token>`
|
||||||
|
- actor headers: `X-User`, `X-Role`
|
||||||
|
|
||||||
|
По умолчанию Vite proxy отправляет `/health` и `/proxy` на `http://localhost:8080`.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте [http://localhost:3000](http://localhost:3000). Перед этим поднимите backend gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../call-center
|
||||||
|
uvicorn gateway.app:app --reload --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Можно переопределить адрес gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_CC_GATEWAY_URL=http://localhost:8080 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gateway deployment
|
||||||
|
|
||||||
|
Production build по умолчанию собирается с base path `/omnichannel/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
`call-center/gateway/app.py` подхватывает build из `../omnichannel-contact-center/dist` и раздает его на:
|
||||||
|
|
||||||
|
- [http://localhost:8080/omnichannel/](http://localhost:8080/omnichannel/)
|
||||||
|
|
||||||
|
При необходимости путь можно поменять через `OMNICHANNEL_UI_DIR`.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||||
|
resolver_timeout 5s;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
location = /health {
|
||||||
|
set $api_gateway http://api-gateway:8000;
|
||||||
|
proxy_pass $api_gateway/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /proxy/ {
|
||||||
|
set $api_gateway http://api-gateway:8000;
|
||||||
|
proxy_pass $api_gateway;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Origin $http_origin;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DigOpsCC</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "OmniChannel Contact Center",
|
||||||
|
"description": "A modern enterprise SaaS platform for contact center operators, supervisors, and admins, featuring omnichannel support and real-time monitoring.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
Generated
+4797
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "digopscc",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^1.29.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1",
|
||||||
|
"react-router-dom": "^7.14.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"vite": "^6.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import { ToastProvider } from './components/ui/Toast';
|
||||||
|
import { MainLayout } from './components/Layout/MainLayout';
|
||||||
|
import { Login } from './pages/Login';
|
||||||
|
import { OperatorView } from './pages/dashboards/OperatorView';
|
||||||
|
import { SupervisorView } from './pages/dashboards/SupervisorView';
|
||||||
|
import { AdminView } from './pages/dashboards/AdminView';
|
||||||
|
import { AnalystView } from './pages/dashboards/AnalystView';
|
||||||
|
import { ChannelType, UserRole } from './types';
|
||||||
|
import { VoiceAIPage } from './pages/VoiceAIPage';
|
||||||
|
|
||||||
|
const routerBasename = import.meta.env.BASE_URL === './' ? '/' : import.meta.env.BASE_URL;
|
||||||
|
|
||||||
|
const DashboardRouter = () => {
|
||||||
|
const { user, isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-indigo-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDashboardByRole = () => {
|
||||||
|
switch (user?.role) {
|
||||||
|
case UserRole.OPERATOR:
|
||||||
|
return <OperatorView />;
|
||||||
|
case UserRole.SUPERVISOR:
|
||||||
|
return <SupervisorView />;
|
||||||
|
case UserRole.ADMIN:
|
||||||
|
return <AdminView />;
|
||||||
|
case UserRole.ANALYST:
|
||||||
|
return <AnalystView />;
|
||||||
|
default:
|
||||||
|
return <OperatorView />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/dashboard" element={getDashboardByRole()} />
|
||||||
|
<Route path="/interactions" element={<OperatorView />} />
|
||||||
|
<Route path="/calls" element={<OperatorView channelFilter={ChannelType.VOICE} title="Голосовые звонки" />} />
|
||||||
|
<Route path="/monitoring" element={<SupervisorView />} />
|
||||||
|
<Route path="/reports" element={<AnalystView />} />
|
||||||
|
<Route path="/users" element={<AdminView initialTab="users" />} />
|
||||||
|
<Route path="/settings" element={<AdminView initialTab="queues" />} />
|
||||||
|
<Route path="/security" element={<AdminView initialTab="users" />} />
|
||||||
|
<Route path="/channels" element={<AdminView initialTab="channels" />} />
|
||||||
|
<Route path="/ai" element={<AdminView initialTab="ai" />} />
|
||||||
|
<Route path="/voice-ai" element={<VoiceAIPage />} />
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<Router basename={routerBasename}>
|
||||||
|
<DashboardRouter />
|
||||||
|
</Router>
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { TopBar } from './TopBar';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
|
||||||
|
export const MainLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex">
|
||||||
|
<Sidebar isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 lg:pl-64">
|
||||||
|
<TopBar onMenuClick={() => setIsSidebarOpen(true)} />
|
||||||
|
<motion.main
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex-1 p-4 md:p-6 overflow-auto"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
Phone,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Zap
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { UserRole } from '../../types';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'dashboard', path: '/dashboard', label: 'Рабочий стол', icon: LayoutDashboard, roles: [UserRole.OPERATOR, UserRole.SUPERVISOR, UserRole.ADMIN, UserRole.ANALYST] },
|
||||||
|
{ id: 'interactions', path: '/interactions', label: 'Обращения', icon: MessageSquare, roles: [UserRole.OPERATOR, UserRole.SUPERVISOR] },
|
||||||
|
{ id: 'calls', path: '/calls', label: 'Звонки', icon: Phone, roles: [UserRole.OPERATOR, UserRole.SUPERVISOR] },
|
||||||
|
{ id: 'voice-ai', path: '/voice-ai', label: 'AI Голос', icon: Mic, roles: [UserRole.OPERATOR, UserRole.SUPERVISOR, UserRole.ADMIN, UserRole.ANALYST] },
|
||||||
|
{ id: 'monitoring', path: '/monitoring', label: 'Мониторинг', icon: Zap, roles: [UserRole.SUPERVISOR, UserRole.ADMIN] },
|
||||||
|
{ id: 'reports', path: '/reports', label: 'Отчеты', icon: BarChart3, roles: [UserRole.SUPERVISOR, UserRole.ANALYST, UserRole.ADMIN] },
|
||||||
|
{ id: 'users', path: '/users', label: 'Пользователи', icon: Users, roles: [UserRole.ADMIN] },
|
||||||
|
{ id: 'settings', path: '/settings', label: 'Настройки', icon: Settings, roles: [UserRole.ADMIN] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
const filteredItems = navItems.filter(item => user && item.roles.includes(user.role));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile Overlay */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-40 lg:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"w-64 h-screen bg-white border-r border-slate-200 flex flex-col fixed left-0 top-0 z-50 transition-transform duration-300 transform lg:translate-x-0",
|
||||||
|
isOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
)}>
|
||||||
|
<div className="p-6 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
||||||
|
<Zap className="text-white w-5 h-5 fill-current" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-bold text-slate-900 text-lg tracking-tight">DigOpsCC</h1>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="lg:hidden p-2 text-slate-400 hover:text-slate-600">
|
||||||
|
<LogOut className="w-5 h-5 rotate-180" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto">
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.id}
|
||||||
|
to={item.path}
|
||||||
|
onClick={onClose}
|
||||||
|
className={({ isActive }) => cn(
|
||||||
|
"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200",
|
||||||
|
isActive
|
||||||
|
? "bg-indigo-50 text-indigo-700 shadow-sm"
|
||||||
|
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<Icon className={cn("w-5 h-5", isActive ? "text-indigo-600" : "text-slate-400")} />
|
||||||
|
{item.label}
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="active-pill"
|
||||||
|
className="ml-auto w-1.5 h-1.5 rounded-full bg-indigo-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-100 italic text-[10px] text-slate-400 text-center">
|
||||||
|
v2.4.0 Высокопроизводительное ядро
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-100">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-rose-600 hover:bg-rose-50 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { Bell, Search, ChevronDown, User as UserIcon, Menu } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { UserStatus, UserRole } from '../../types';
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
[UserStatus.ONLINE]: 'bg-emerald-500',
|
||||||
|
[UserStatus.AWAY]: 'bg-amber-500',
|
||||||
|
[UserStatus.BUSY]: 'bg-rose-500',
|
||||||
|
[UserStatus.OFFLINE]: 'bg-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TopBar: React.FC<{ onMenuClick: () => void }> = ({ onMenuClick }) => {
|
||||||
|
const { user, logout, setStatus } = useAuth();
|
||||||
|
const [showStatusMenu, setShowStatusMenu] = React.useState(false);
|
||||||
|
|
||||||
|
const roleLabels: Record<UserRole, string> = {
|
||||||
|
[UserRole.OPERATOR]: 'Оператор',
|
||||||
|
[UserRole.SUPERVISOR]: 'Супервайзер',
|
||||||
|
[UserRole.ADMIN]: 'Администратор',
|
||||||
|
[UserRole.ANALYST]: 'Аналитик',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-4 md:px-6 sticky top-0 z-30 w-full transition-all">
|
||||||
|
<div className="flex items-center gap-3 flex-1 max-w-md">
|
||||||
|
<button
|
||||||
|
onClick={onMenuClick}
|
||||||
|
className="lg:hidden p-2 text-slate-500 hover:bg-slate-50 rounded-lg transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="relative w-full hidden sm:block">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск обращений, клиентов..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button className="p-2 text-slate-500 hover:bg-slate-50 rounded-lg relative transition-colors">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
<span className="absolute top-2 right-2 w-2 h-2 bg-rose-500 border-2 border-white rounded-full"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-8 w-px bg-slate-200 mx-2"></div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pl-2 group relative">
|
||||||
|
<div className="text-right flex flex-col" onClick={() => setShowStatusMenu(!showStatusMenu)}>
|
||||||
|
<span className="text-sm font-semibold text-slate-900 leading-tight group-hover:text-indigo-600 transition-colors cursor-pointer">{user.name}</span>
|
||||||
|
<span className="text-[11px] font-medium text-slate-500 uppercase tracking-wider">{roleLabels[user.role]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative cursor-pointer" onClick={() => setShowStatusMenu(!showStatusMenu)}>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-slate-100 border border-slate-200 overflow-hidden">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt={user.name} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-slate-400">
|
||||||
|
<UserIcon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-white",
|
||||||
|
statusColors[user.status]
|
||||||
|
)}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronDown
|
||||||
|
className={cn("w-4 h-4 text-slate-400 group-hover:text-slate-600 transition-all cursor-pointer", showStatusMenu && "rotate-180")}
|
||||||
|
onClick={() => setShowStatusMenu(!showStatusMenu)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showStatusMenu && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 w-48 bg-white rounded-xl shadow-xl border border-slate-100 py-2 z-50 animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="px-4 py-2 text-[10px] font-bold text-slate-400 uppercase tracking-widest">Изменить статус</div>
|
||||||
|
{Object.values(UserStatus).map((status) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
[UserStatus.ONLINE]: 'В сети',
|
||||||
|
[UserStatus.AWAY]: 'Отошел',
|
||||||
|
[UserStatus.BUSY]: 'Занят',
|
||||||
|
[UserStatus.OFFLINE]: 'Не в сети'
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => {
|
||||||
|
setStatus(status);
|
||||||
|
setShowStatusMenu(false);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors"
|
||||||
|
>
|
||||||
|
<div className={cn("w-2 h-2 rounded-full", statusColors[status])}></div>
|
||||||
|
<span className="capitalize">{labels[status]}</span>
|
||||||
|
{user.status === status && <div className="ml-auto w-1 h-1 rounded-full bg-indigo-500"></div>}
|
||||||
|
</button>
|
||||||
|
)})}
|
||||||
|
<div className="border-t border-slate-100 my-1"></div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2 text-sm text-rose-600 hover:bg-rose-50 transition-colors"
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Loader2, AlertCircle, Inbox } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export const LoadingState: React.FC<{ message?: string; className?: string }> = ({ message = 'Syncing data...', className }) => (
|
||||||
|
<div className={cn("flex flex-col items-center justify-center p-12 text-center", className)}>
|
||||||
|
<div className="w-16 h-16 bg-slate-50 rounded-3xl flex items-center justify-center mb-4 border border-slate-100 shadow-sm">
|
||||||
|
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-bold text-slate-900 italic tracking-tight">{message}</h3>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Please wait while the quantum cores stabilize.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EmptyState: React.FC<{ title: string; description: string; icon?: any; action?: React.ReactNode; className?: string }> = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon = Inbox,
|
||||||
|
action,
|
||||||
|
className
|
||||||
|
}) => (
|
||||||
|
<div className={cn("flex flex-col items-center justify-center p-12 text-center", className)}>
|
||||||
|
<div className="w-20 h-20 bg-slate-50 rounded-[2rem] flex items-center justify-center mb-6 border border-slate-100/50 shadow-sm group">
|
||||||
|
<Icon className="w-10 h-10 text-slate-300 group-hover:text-indigo-400 transition-colors duration-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-900 italic tracking-tight mb-2">{title}</h3>
|
||||||
|
<p className="text-sm text-slate-500 max-w-xs mx-auto italic mb-8">{description}</p>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ErrorState: React.FC<{ message: string; onRetry?: () => void; className?: string }> = ({ message, onRetry, className }) => (
|
||||||
|
<div className={cn("flex flex-col items-center justify-center p-12 text-center bg-rose-50/30 rounded-3xl border border-rose-100", className)}>
|
||||||
|
<div className="w-16 h-16 bg-rose-100 rounded-3xl flex items-center justify-center mb-4 border border-rose-200 shadow-sm">
|
||||||
|
<AlertCircle className="w-8 h-8 text-rose-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-bold text-rose-900 italic tracking-tight">System Fault Detected</h3>
|
||||||
|
<p className="text-xs text-rose-600/70 mt-1 mb-6 max-w-xs">{message}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="px-6 py-2 bg-rose-600 text-white rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-rose-700 transition-all shadow-md active:scale-95"
|
||||||
|
>
|
||||||
|
Re-sync Module
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import { CheckCircle2, XCircle, AlertCircle, Info, X } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
showToast: (type: ToastType, title: string, message?: string) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) throw new Error('useToast must be used within ToastProvider');
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showToast = useCallback((type: ToastType, title: string, message?: string) => {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
setToasts((prev) => [...prev, { id, type, title, message }]);
|
||||||
|
setTimeout(() => removeToast(id), 5000);
|
||||||
|
}, [removeToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed bottom-6 right-6 z-[100] flex flex-col gap-3 w-full max-w-sm pointer-events-none">
|
||||||
|
<AnimatePresence>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToastItem: React.FC<{ toast: Toast; onClose: () => void }> = ({ toast, onClose }) => {
|
||||||
|
const icons = {
|
||||||
|
success: <CheckCircle2 className="w-5 h-5 text-emerald-500" />,
|
||||||
|
error: <XCircle className="w-5 h-5 text-rose-500" />,
|
||||||
|
warning: <AlertCircle className="w-5 h-5 text-amber-500" />,
|
||||||
|
info: <Info className="w-5 h-5 text-indigo-500" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bgColors = {
|
||||||
|
success: 'bg-emerald-50 border-emerald-100',
|
||||||
|
error: 'bg-rose-50 border-rose-100',
|
||||||
|
warning: 'bg-amber-50 border-amber-100',
|
||||||
|
info: 'bg-indigo-50 border-indigo-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, x: 20, scale: 0.95 }}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto p-4 rounded-2xl border shadow-xl flex items-start gap-3",
|
||||||
|
bgColors[toast.type]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="shrink-0 mt-0.5">{icons[toast.type]}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-bold text-slate-900 leading-tight italic">{toast.title}</h4>
|
||||||
|
{toast.message && <p className="text-xs text-slate-600 mt-1">{toast.message}</p>}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-600 transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { User, UserStatus } from '../types';
|
||||||
|
import {
|
||||||
|
AuthSession,
|
||||||
|
clearStoredSession,
|
||||||
|
getStoredSession,
|
||||||
|
loginWithPassword,
|
||||||
|
roleFromBackend,
|
||||||
|
roleToBackend,
|
||||||
|
saveStoredSession,
|
||||||
|
sessionToUser,
|
||||||
|
} from '../lib/api';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
session: AuthSession | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
applySession: (session: AuthSession) => void;
|
||||||
|
logout: () => void;
|
||||||
|
setStatus: (status: UserStatus) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [session, setSession] = useState<AuthSession | null>(null);
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedSession = getStoredSession();
|
||||||
|
if (savedSession) {
|
||||||
|
setSession(savedSession);
|
||||||
|
setUser(sessionToUser(savedSession));
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
const handleAuthExpired = () => {
|
||||||
|
setSession(null);
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('digopscc-auth-expired', handleAuthExpired);
|
||||||
|
return () => window.removeEventListener('digopscc-auth-expired', handleAuthExpired);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applySession = useCallback((nextSession: AuthSession) => {
|
||||||
|
const normalizedSession: AuthSession = {
|
||||||
|
...nextSession,
|
||||||
|
role: roleFromBackend(nextSession.backendRole || nextSession.role),
|
||||||
|
backendRole: roleToBackend(nextSession.backendRole || nextSession.role),
|
||||||
|
};
|
||||||
|
setSession(normalizedSession);
|
||||||
|
setUser(sessionToUser(normalizedSession));
|
||||||
|
saveStoredSession(normalizedSession);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (username: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const nextSession = await loginWithPassword(username.trim(), password);
|
||||||
|
applySession(nextSession);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applySession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setUser(null);
|
||||||
|
setSession(null);
|
||||||
|
clearStoredSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setStatus = useCallback((status: UserStatus) => {
|
||||||
|
setUser((current) => (current ? { ...current, status } : current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
session,
|
||||||
|
isAuthenticated: Boolean(session && user),
|
||||||
|
login,
|
||||||
|
applySession,
|
||||||
|
logout,
|
||||||
|
setStatus,
|
||||||
|
isLoading,
|
||||||
|
}),
|
||||||
|
[applySession, isLoading, login, logout, session, setStatus, user],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply font-sans text-slate-900 antialiased bg-slate-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-slate-200 rounded-full hover:bg-slate-300 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth Transitions */
|
||||||
|
* {
|
||||||
|
@apply transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-morphism {
|
||||||
|
@apply backdrop-blur-md bg-white/70 border border-white/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-slow {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-slow {
|
||||||
|
animation: pulse-slow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
+387
@@ -0,0 +1,387 @@
|
|||||||
|
import { ChannelType, User, UserRole, UserStatus } from '../types';
|
||||||
|
|
||||||
|
const SESSION_STORAGE_KEY = 'digopscc_session';
|
||||||
|
const API_BASE = String(import.meta.env.VITE_API_BASE || '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
export type BackendRole = 'operator' | 'supervisor' | 'admin' | 'analyst';
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
accessToken: string;
|
||||||
|
tokenType: string;
|
||||||
|
username: string;
|
||||||
|
fullName?: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
backendRole: BackendRole;
|
||||||
|
authSource?: string | null;
|
||||||
|
provider?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type?: string;
|
||||||
|
role: BackendRole;
|
||||||
|
auth_source?: string | null;
|
||||||
|
provider?: string | null;
|
||||||
|
full_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OidcConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
login_path?: string;
|
||||||
|
provider_label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendInteraction {
|
||||||
|
interaction_id: string;
|
||||||
|
channel: string;
|
||||||
|
subject: string;
|
||||||
|
customer_id?: string | null;
|
||||||
|
queue_id?: string | null;
|
||||||
|
priority: number;
|
||||||
|
status: string;
|
||||||
|
assigned_to?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendThread {
|
||||||
|
thread_id: string;
|
||||||
|
chat_id: string;
|
||||||
|
interaction_id: string;
|
||||||
|
username?: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
phone_number?: string | null;
|
||||||
|
queue_id?: string | null;
|
||||||
|
status: string;
|
||||||
|
claimed_by_user?: string | null;
|
||||||
|
ai_state?: string | null;
|
||||||
|
ai_handoff_reason?: string | null;
|
||||||
|
unread_count?: number;
|
||||||
|
last_message_at: string;
|
||||||
|
last_message_preview: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendThreadMessage {
|
||||||
|
message_id: string;
|
||||||
|
thread_id: string;
|
||||||
|
interaction_id: string;
|
||||||
|
chat_id: string;
|
||||||
|
direction: 'inbound' | 'outbound' | 'system';
|
||||||
|
text: string;
|
||||||
|
operator_user?: string | null;
|
||||||
|
author_type?: 'customer' | 'human' | 'ai' | 'system';
|
||||||
|
delivery_status?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendLiveCall {
|
||||||
|
call_id: string;
|
||||||
|
interaction_id: string;
|
||||||
|
queue_id: string;
|
||||||
|
queue_code?: string;
|
||||||
|
caller_number?: string | null;
|
||||||
|
caller_name?: string | null;
|
||||||
|
status: string;
|
||||||
|
telephony_status?: string;
|
||||||
|
claimed_by_user?: string | null;
|
||||||
|
operator_extension?: string | null;
|
||||||
|
started_at: string;
|
||||||
|
connected_at?: string | null;
|
||||||
|
ended_at?: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
ai_state?: string | null;
|
||||||
|
ai_handoff_reason?: string | null;
|
||||||
|
has_recording?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendAgentState {
|
||||||
|
agent_id: string;
|
||||||
|
state: 'READY' | 'BUSY' | 'BREAK' | 'OFFLINE';
|
||||||
|
queue_id?: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendRealtime {
|
||||||
|
agents: {
|
||||||
|
total: number;
|
||||||
|
by_state: Record<string, number>;
|
||||||
|
items: BackendAgentState[];
|
||||||
|
};
|
||||||
|
queues: Array<{
|
||||||
|
queue_id: string;
|
||||||
|
in_queue: number;
|
||||||
|
avg_wait_seconds: number;
|
||||||
|
updated_at: string;
|
||||||
|
}>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendQueue {
|
||||||
|
queue_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
rules: Array<{
|
||||||
|
channel: string;
|
||||||
|
priority: number;
|
||||||
|
strategy: string;
|
||||||
|
sla_seconds: number;
|
||||||
|
}>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendUser {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
full_name: string;
|
||||||
|
role: BackendRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendKpi {
|
||||||
|
volume?: {
|
||||||
|
total?: number;
|
||||||
|
answered?: number;
|
||||||
|
abandoned?: number;
|
||||||
|
};
|
||||||
|
kpi?: Record<string, number>;
|
||||||
|
breakdowns?: {
|
||||||
|
by_channel?:
|
||||||
|
| Array<{ channel: string; total: number; answered?: number; abandoned?: number }>
|
||||||
|
| Record<string, { total: number; answered?: number; abandoned?: number }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendTimeseries {
|
||||||
|
points?: Array<{
|
||||||
|
ts?: string;
|
||||||
|
bucket?: string;
|
||||||
|
label?: string;
|
||||||
|
value?: number;
|
||||||
|
sample_size?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendDrilldown {
|
||||||
|
items: BackendInteraction[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendAgentOverviewItem {
|
||||||
|
agent_id: string;
|
||||||
|
current_state?: 'READY' | 'BUSY' | 'BREAK' | 'OFFLINE' | null;
|
||||||
|
current_queue_id?: string | null;
|
||||||
|
dominant_queue_id?: string | null;
|
||||||
|
interactions_total: number;
|
||||||
|
answered_total: number;
|
||||||
|
closed_total: number;
|
||||||
|
abandoned_total: number;
|
||||||
|
avg_handle_seconds?: number | null;
|
||||||
|
answer_rate: number;
|
||||||
|
fcr_rate?: number | null;
|
||||||
|
last_activity_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendAgentOverview {
|
||||||
|
totals: {
|
||||||
|
agents_total: number;
|
||||||
|
agents_with_activity: number;
|
||||||
|
interactions_total: number;
|
||||||
|
answered_total: number;
|
||||||
|
ready_now: number;
|
||||||
|
busy_now: number;
|
||||||
|
break_now: number;
|
||||||
|
offline_now: number;
|
||||||
|
avg_handle_seconds?: number | null;
|
||||||
|
avg_fcr_rate?: number | null;
|
||||||
|
};
|
||||||
|
items: BackendAgentOverviewItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendQueueOverviewItem {
|
||||||
|
queue_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
interactions_total: number;
|
||||||
|
answered_total: number;
|
||||||
|
abandoned_total: number;
|
||||||
|
service_level?: number | null;
|
||||||
|
answer_rate?: number | null;
|
||||||
|
fcr_rate?: number | null;
|
||||||
|
avg_handle_seconds?: number | null;
|
||||||
|
in_queue: number;
|
||||||
|
avg_wait_seconds: number;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendQueueOverview {
|
||||||
|
items: BackendQueueOverviewItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendOmnichannelInsights {
|
||||||
|
summary_text: string;
|
||||||
|
primary_metric: { label: string; value: string };
|
||||||
|
secondary_metric: { label: string; value: string };
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
detail: unknown;
|
||||||
|
|
||||||
|
constructor(status: number, detail: unknown) {
|
||||||
|
super(`${status}: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const roleToBackend = (role: UserRole | string | undefined): BackendRole => {
|
||||||
|
const normalized = String(role || UserRole.OPERATOR).toLowerCase();
|
||||||
|
if (normalized.includes('admin')) return 'admin';
|
||||||
|
if (normalized.includes('supervisor')) return 'supervisor';
|
||||||
|
if (normalized.includes('analyst')) return 'analyst';
|
||||||
|
return 'operator';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roleFromBackend = (role: string | undefined): UserRole => {
|
||||||
|
switch (String(role || '').toLowerCase()) {
|
||||||
|
case 'admin':
|
||||||
|
return UserRole.ADMIN;
|
||||||
|
case 'supervisor':
|
||||||
|
return UserRole.SUPERVISOR;
|
||||||
|
case 'analyst':
|
||||||
|
return UserRole.ANALYST;
|
||||||
|
default:
|
||||||
|
return UserRole.OPERATOR;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const channelFromBackend = (channel: string | undefined): ChannelType => {
|
||||||
|
switch (String(channel || '').toLowerCase()) {
|
||||||
|
case 'telegram':
|
||||||
|
return ChannelType.TELEGRAM;
|
||||||
|
case 'whatsapp':
|
||||||
|
return ChannelType.WHATSAPP;
|
||||||
|
case 'webchat':
|
||||||
|
return ChannelType.WEBCHAT;
|
||||||
|
case 'email':
|
||||||
|
return ChannelType.EMAIL;
|
||||||
|
default:
|
||||||
|
return ChannelType.VOICE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sessionToUser = (session: AuthSession): User => ({
|
||||||
|
id: session.username,
|
||||||
|
username: session.username,
|
||||||
|
email: session.username.includes('@') ? session.username : `${session.username}@call-center.local`,
|
||||||
|
name: session.fullName || session.username,
|
||||||
|
role: session.role,
|
||||||
|
status: UserStatus.ONLINE,
|
||||||
|
avatarUrl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getStoredSession = (): AuthSession | null => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as AuthSession;
|
||||||
|
if (!parsed?.accessToken || !parsed?.username) return null;
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
role: roleFromBackend(parsed.backendRole || parsed.role),
|
||||||
|
backendRole: roleToBackend(parsed.backendRole || parsed.role),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveStoredSession = (session: AuthSession) => {
|
||||||
|
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearStoredSession = () => {
|
||||||
|
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUrl = (path: string) => `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
|
||||||
|
const parseResponseBody = async (response: Response) => {
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function gatewayRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const response = await fetch(buildUrl(path), options);
|
||||||
|
const payload = await parseResponseBody(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, payload);
|
||||||
|
}
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequest<T>(
|
||||||
|
service: string,
|
||||||
|
path: string,
|
||||||
|
options: RequestInit & { session?: AuthSession | null } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const { session = getStoredSession(), headers: optionHeaders, ...fetchOptions } = options;
|
||||||
|
const headers = new Headers(optionHeaders);
|
||||||
|
const body = fetchOptions.body;
|
||||||
|
|
||||||
|
if (body && !(body instanceof FormData) && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
if (session) {
|
||||||
|
headers.set('X-User', session.username);
|
||||||
|
headers.set('X-Role', session.backendRole);
|
||||||
|
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildUrl(`/proxy/${service}/${path}`), {
|
||||||
|
...fetchOptions,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const payload = await parseResponseBody(response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearStoredSession();
|
||||||
|
window.dispatchEvent(new CustomEvent('digopscc-auth-expired'));
|
||||||
|
}
|
||||||
|
throw new ApiError(response.status, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loginWithPassword = async (username: string, password: string): Promise<AuthSession> => {
|
||||||
|
const data = await apiRequest<LoginResponse>('auth', 'auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
const role = roleFromBackend(data.role);
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
tokenType: data.token_type || 'bearer',
|
||||||
|
username,
|
||||||
|
fullName: data.full_name,
|
||||||
|
role,
|
||||||
|
backendRole: roleToBackend(data.role),
|
||||||
|
authSource: data.auth_source || 'local',
|
||||||
|
provider: data.provider,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOidcConfig = () => gatewayRequest<OidcConfig>('/proxy/auth/auth/oidc/config');
|
||||||
|
|
||||||
|
export const health = () => gatewayRequest<{ status: string; service: string; version?: string }>('/health');
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { UserRole } from '../types';
|
||||||
|
import { AlertCircle, HelpCircle, Loader2, Shield, Zap } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { AuthSession, getOidcConfig, roleFromBackend, roleToBackend } from '../lib/api';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
|
||||||
|
const rolePresets: Record<UserRole, { label: string; description: string; username: string; password: string }> = {
|
||||||
|
[UserRole.OPERATOR]: {
|
||||||
|
label: 'Оператор',
|
||||||
|
description: 'Работа с запросами',
|
||||||
|
username: 'operator',
|
||||||
|
password: 'op12345',
|
||||||
|
},
|
||||||
|
[UserRole.SUPERVISOR]: {
|
||||||
|
label: 'Супервайзер',
|
||||||
|
description: 'Команды и очереди',
|
||||||
|
username: 'supervisor',
|
||||||
|
password: 'sup12345',
|
||||||
|
},
|
||||||
|
[UserRole.ADMIN]: {
|
||||||
|
label: 'Админ',
|
||||||
|
description: 'Настройка системы',
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin123',
|
||||||
|
},
|
||||||
|
[UserRole.ANALYST]: {
|
||||||
|
label: 'Аналитик',
|
||||||
|
description: 'KPI и инсайты',
|
||||||
|
username: 'analyst',
|
||||||
|
password: 'an12345',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Login: React.FC = () => {
|
||||||
|
const { login, applySession } = useAuth();
|
||||||
|
const [username, setUsername] = React.useState(rolePresets[UserRole.OPERATOR].username);
|
||||||
|
const [password, setPassword] = React.useState(rolePresets[UserRole.OPERATOR].password);
|
||||||
|
const [role, setRole] = React.useState<UserRole>(UserRole.OPERATOR);
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [oidc, setOidc] = React.useState({ enabled: false, loginPath: '', providerLabel: 'SSO' });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
getOidcConfig()
|
||||||
|
.then((config) => {
|
||||||
|
setOidc({
|
||||||
|
enabled: Boolean(config.enabled),
|
||||||
|
loginPath: config.login_path || '/auth/oidc/start?return_mode=popup',
|
||||||
|
providerLabel: config.provider_label || 'Keycloak',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setOidc((current) => ({ ...current, enabled: false }));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleOidcMessage = (event: MessageEvent) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || typeof data !== 'object') return;
|
||||||
|
if (data.type === 'oidc-login' && data.access_token) {
|
||||||
|
const backendRole = roleToBackend(data.role);
|
||||||
|
const session: AuthSession = {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
tokenType: 'bearer',
|
||||||
|
username: data.username || data.email || 'oidc-user',
|
||||||
|
fullName: data.full_name || data.username || data.email,
|
||||||
|
role: roleFromBackend(backendRole),
|
||||||
|
backendRole,
|
||||||
|
authSource: data.auth_source || 'oidc',
|
||||||
|
provider: data.provider,
|
||||||
|
};
|
||||||
|
applySession(session);
|
||||||
|
}
|
||||||
|
if (data.type === 'oidc-error') {
|
||||||
|
setError(data.message || 'Корпоративная авторизация не выполнена');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleOidcMessage);
|
||||||
|
return () => window.removeEventListener('message', handleOidcMessage);
|
||||||
|
}, [applySession]);
|
||||||
|
|
||||||
|
const selectRole = (nextRole: UserRole) => {
|
||||||
|
const preset = rolePresets[nextRole];
|
||||||
|
setRole(nextRole);
|
||||||
|
setUsername(preset.username);
|
||||||
|
setPassword(preset.password);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Не удалось авторизоваться через backend';
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCorporateLogin = () => {
|
||||||
|
if (!oidc.enabled) {
|
||||||
|
setError('Корпоративный вход сейчас недоступен в auth-service.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const popup = window.open(`/proxy/auth${oidc.loginPath}`, 'oidc-login', 'width=620,height=760');
|
||||||
|
if (!popup) {
|
||||||
|
setError('Браузер заблокировал окно корпоративного входа.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen grid lg:grid-cols-2">
|
||||||
|
<div className="hidden lg:flex bg-indigo-600 relative overflow-hidden flex-col justify-between p-12 text-white">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-center gap-3 mb-12">
|
||||||
|
<div className="w-10 h-10 bg-white/20 backdrop-blur-md rounded-xl flex items-center justify-center">
|
||||||
|
<Zap className="text-white w-6 h-6 fill-current" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-2xl tracking-tight">DigOpsCC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-5xl font-bold leading-[1.1] mb-6">
|
||||||
|
Новый интерфейс <br />
|
||||||
|
<span className="text-indigo-200 text-6xl">того же call-center ядра.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-indigo-100 text-lg max-w-md">
|
||||||
|
DigOpsCC работает через существующий API Gateway, auth-service, routing, supervisor,
|
||||||
|
reporting и канальные адаптеры.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 grid grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-indigo-200 text-xs font-semibold uppercase tracking-widest mb-1">Gateway</h3>
|
||||||
|
<span className="text-2xl font-mono">:8080</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-indigo-200 text-xs font-semibold uppercase tracking-widest mb-1">Backend</h3>
|
||||||
|
<span className="text-2xl font-mono">/proxy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center px-8 sm:px-16 lg:px-24 bg-white">
|
||||||
|
<div className="max-w-md w-full mx-auto">
|
||||||
|
<div className="lg:hidden flex items-center gap-2 mb-12">
|
||||||
|
<Zap className="text-indigo-600 w-8 h-8 fill-current" />
|
||||||
|
<span className="font-bold text-2xl">DigOpsCC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8 font-sans">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 mb-2">Вход через call-center</h2>
|
||||||
|
<p className="text-slate-500">Используются учетные записи auth-service, как в старом UI.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-semibold text-slate-700">Быстрый профиль</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{Object.values(UserRole).map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectRole(item)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-3 rounded-xl border text-sm font-medium transition-all text-left flex flex-col gap-1',
|
||||||
|
role === item
|
||||||
|
? 'border-indigo-600 bg-indigo-50 text-indigo-700 ring-4 ring-indigo-500/10'
|
||||||
|
: 'border-slate-200 text-slate-600 hover:border-slate-300 hover:bg-slate-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{rolePresets[item].label}</span>
|
||||||
|
<span className="text-[10px] opacity-70 font-normal">{rolePresets[item].description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold text-slate-700" htmlFor="username">
|
||||||
|
Логин
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="operator"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold text-slate-700" htmlFor="password">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="op12345"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<span className="break-words">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-slate-900 text-white rounded-xl py-4 font-semibold hover:bg-slate-800 focus:ring-4 focus:ring-slate-900/20 transition-all flex items-center justify-center gap-2 group disabled:bg-slate-400"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Проверка в auth-service...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Войти в консоль
|
||||||
|
<motion.span animate={{ x: [0, 4, 0] }} transition={{ repeat: Infinity, duration: 1.5 }}>
|
||||||
|
→
|
||||||
|
</motion.span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-slate-100 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startCorporateLogin}
|
||||||
|
className="text-sm text-slate-500 hover:text-indigo-600 flex items-center gap-2 transition-colors disabled:text-slate-300"
|
||||||
|
disabled={!oidc.enabled}
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
{oidc.enabled ? `SSO ${oidc.providerLabel}` : 'SSO недоступен'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/health"
|
||||||
|
className="text-sm text-slate-500 hover:text-indigo-600 flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
Health
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Mic, PhoneCall, PhoneOff, RotateCcw } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
const TARGET_SAMPLE_RATE = 16000;
|
||||||
|
|
||||||
|
type MsgRole = 'user' | 'assistant' | 'system';
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: number;
|
||||||
|
role: MsgRole;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downsample(buf: Float32Array, inRate: number, outRate: number): Float32Array {
|
||||||
|
if (inRate === outRate) return buf;
|
||||||
|
const ratio = inRate / outRate;
|
||||||
|
const len = Math.round(buf.length / ratio);
|
||||||
|
const out = new Float32Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const start = Math.round(i * ratio);
|
||||||
|
const end = Math.round((i + 1) * ratio);
|
||||||
|
let acc = 0, count = 0;
|
||||||
|
for (let j = start; j < end && j < buf.length; j++) { acc += buf[j]; count++; }
|
||||||
|
out[i] = acc / Math.max(count, 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatTo16Bit(buf: Float32Array): ArrayBuffer {
|
||||||
|
const out = new Int16Array(buf.length);
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, buf[i]));
|
||||||
|
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||||
|
}
|
||||||
|
return out.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VoiceAIPage: React.FC = () => {
|
||||||
|
// ── UI state ────────────────────────────────────────────────────────────────
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [statusText, setStatusText] = useState('offline');
|
||||||
|
const [statusKind, setStatusKind] = useState('');
|
||||||
|
const [partialText, setPartialText] = useState('Готов к звонку.');
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [levelPct, setLevelPct] = useState(0);
|
||||||
|
|
||||||
|
// ── Mutable refs (avoid stale closures in audio callbacks) ──────────────────
|
||||||
|
const isRunningRef = useRef(false);
|
||||||
|
const isMutedRef = useRef(false);
|
||||||
|
const isStoppingRef = useRef(false);
|
||||||
|
const lastErrRef = useRef('');
|
||||||
|
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
|
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
const playbackCursorRef = useRef(0);
|
||||||
|
const chainRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
const pendingEncodedRef = useRef(0);
|
||||||
|
const activeAudiosRef = useRef<Set<HTMLAudioElement>>(new Set());
|
||||||
|
const unmuteTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const currentMsgIdRef = useRef<number | null>(null);
|
||||||
|
const currentTextRef = useRef('');
|
||||||
|
const currentChunksRef = useRef(0);
|
||||||
|
const currentTtsFailedRef = useRef(false);
|
||||||
|
const currentTtsMsgRef = useRef('');
|
||||||
|
|
||||||
|
const msgCounterRef = useRef(0);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
// Keep refs in sync with state
|
||||||
|
useEffect(() => { isRunningRef.current = isRunning; }, [isRunning]);
|
||||||
|
useEffect(() => { isMutedRef.current = isMuted; }, [isMuted]);
|
||||||
|
|
||||||
|
// ── Canvas helpers ───────────────────────────────────────────────────────────
|
||||||
|
const drawIdle = useCallback(() => {
|
||||||
|
const c = canvasRef.current;
|
||||||
|
if (!c) return;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.fillStyle = '#0f172a';
|
||||||
|
ctx.fillRect(0, 0, c.width, c.height);
|
||||||
|
ctx.strokeStyle = '#2f9e80';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
const mid = c.height / 2;
|
||||||
|
for (let x = 0; x < c.width; x++) {
|
||||||
|
const y = mid + Math.sin(x / 28) * 8 + Math.sin(x / 83) * 14;
|
||||||
|
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const drawWave = useCallback((buf: Float32Array) => {
|
||||||
|
const c = canvasRef.current;
|
||||||
|
if (!c) return;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.fillStyle = '#0f172a';
|
||||||
|
ctx.fillRect(0, 0, c.width, c.height);
|
||||||
|
ctx.strokeStyle = isMutedRef.current ? '#ef4444' : '#22c55e';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
const slice = Math.max(1, Math.floor(buf.length / c.width));
|
||||||
|
const mid = c.height / 2;
|
||||||
|
for (let x = 0; x < c.width; x++) {
|
||||||
|
const y = mid + (buf[x * slice] || 0) * mid * 0.8;
|
||||||
|
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { drawIdle(); }, [drawIdle]);
|
||||||
|
|
||||||
|
// ── Audio playback ───────────────────────────────────────────────────────────
|
||||||
|
const scheduleUnmute = useCallback(() => {
|
||||||
|
if (pendingEncodedRef.current > 0) return;
|
||||||
|
if (unmuteTimerRef.current) clearTimeout(unmuteTimerRef.current);
|
||||||
|
const ac = audioCtxRef.current;
|
||||||
|
const ms = ac ? Math.max(0, (playbackCursorRef.current - ac.currentTime) * 1000) : 0;
|
||||||
|
unmuteTimerRef.current = window.setTimeout(() => setIsMuted(false), ms + 120);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playPcm = useCallback((b64: string, sampleRate: number) => {
|
||||||
|
const ac = audioCtxRef.current;
|
||||||
|
if (!ac) return;
|
||||||
|
if (ac.state === 'suspended') ac.resume();
|
||||||
|
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||||
|
const samples = new Int16Array(bytes.buffer);
|
||||||
|
const ab = ac.createBuffer(1, samples.length, sampleRate);
|
||||||
|
const ch = ab.getChannelData(0);
|
||||||
|
for (let i = 0; i < samples.length; i++) ch[i] = samples[i] / 32768;
|
||||||
|
const src = ac.createBufferSource();
|
||||||
|
src.buffer = ab;
|
||||||
|
src.connect(ac.destination);
|
||||||
|
const at = Math.max(ac.currentTime + 0.03, playbackCursorRef.current);
|
||||||
|
src.start(at);
|
||||||
|
playbackCursorRef.current = at + ab.duration;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playEncoded = useCallback((b64: string, mime: string) => {
|
||||||
|
pendingEncodedRef.current += 1;
|
||||||
|
chainRef.current = chainRef.current.then(
|
||||||
|
() => new Promise<void>(resolve => {
|
||||||
|
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||||
|
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
|
||||||
|
const audio = new Audio(url);
|
||||||
|
activeAudiosRef.current.add(audio);
|
||||||
|
const cleanup = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
activeAudiosRef.current.delete(audio);
|
||||||
|
pendingEncodedRef.current = Math.max(0, pendingEncodedRef.current - 1);
|
||||||
|
if (pendingEncodedRef.current === 0 && isMutedRef.current) scheduleUnmute();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
audio.onended = cleanup;
|
||||||
|
audio.onerror = cleanup;
|
||||||
|
audio.play().catch(cleanup);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [scheduleUnmute]);
|
||||||
|
|
||||||
|
// ── Transcript helpers ───────────────────────────────────────────────────────
|
||||||
|
const addMsg = useCallback((role: MsgRole, text: string): number => {
|
||||||
|
const id = ++msgCounterRef.current;
|
||||||
|
setMessages(prev => [...prev, { id, role, text }]);
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateMsg = useCallback((id: number, text: string) => {
|
||||||
|
setMessages(prev => prev.map(m => m.id === id ? { ...m, text } : m));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, partialText]);
|
||||||
|
|
||||||
|
// ── Browser TTS fallback ─────────────────────────────────────────────────────
|
||||||
|
const browserFallback = useCallback((): boolean => {
|
||||||
|
if (currentChunksRef.current > 0 || !currentTextRef.current.trim()) return false;
|
||||||
|
if (!('speechSynthesis' in window)) {
|
||||||
|
setPartialText(currentTtsFailedRef.current ? currentTtsMsgRef.current : 'TTS не ответил.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
const u = new SpeechSynthesisUtterance(currentTextRef.current);
|
||||||
|
u.lang = 'ru-RU';
|
||||||
|
u.rate = 1.04;
|
||||||
|
const done = () => { setIsMuted(false); setStatusText('live'); setStatusKind('live'); setPartialText('Слушаю...'); };
|
||||||
|
u.onend = done;
|
||||||
|
u.onerror = done;
|
||||||
|
setIsMuted(true);
|
||||||
|
setStatusText('browser tts');
|
||||||
|
setStatusKind('busy');
|
||||||
|
setPartialText('Резервная озвучка браузера...');
|
||||||
|
window.speechSynthesis.speak(u);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── WebSocket message handler ────────────────────────────────────────────────
|
||||||
|
const handleMsg = useCallback((e: MessageEvent) => {
|
||||||
|
const d = JSON.parse(e.data as string);
|
||||||
|
|
||||||
|
switch (d.type) {
|
||||||
|
case 'ready':
|
||||||
|
setStatusText(d.model || 'ready');
|
||||||
|
setStatusKind('live');
|
||||||
|
break;
|
||||||
|
case 'stt_ready':
|
||||||
|
setPartialText('Слушаю...');
|
||||||
|
break;
|
||||||
|
case 'stt_partial':
|
||||||
|
setPartialText(d.text || 'Слушаю...');
|
||||||
|
break;
|
||||||
|
case 'user_final':
|
||||||
|
currentMsgIdRef.current = null;
|
||||||
|
addMsg('user', d.text);
|
||||||
|
setPartialText('Думаю...');
|
||||||
|
break;
|
||||||
|
case 'assistant_started':
|
||||||
|
setIsMuted(true);
|
||||||
|
currentMsgIdRef.current = addMsg('assistant', '');
|
||||||
|
currentTextRef.current = '';
|
||||||
|
currentChunksRef.current = 0;
|
||||||
|
currentTtsFailedRef.current = false;
|
||||||
|
currentTtsMsgRef.current = '';
|
||||||
|
setStatusText('отвечает');
|
||||||
|
setStatusKind('busy');
|
||||||
|
break;
|
||||||
|
case 'assistant_delta':
|
||||||
|
if (currentMsgIdRef.current !== null) {
|
||||||
|
currentTextRef.current += d.text || '';
|
||||||
|
updateMsg(currentMsgIdRef.current, currentTextRef.current);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'assistant_text_done':
|
||||||
|
currentTextRef.current = d.text || currentTextRef.current;
|
||||||
|
setPartialText('Озвучиваю...');
|
||||||
|
break;
|
||||||
|
case 'tts_audio':
|
||||||
|
currentChunksRef.current += 1;
|
||||||
|
if ((d.format || '').startsWith('mp3') || (d.mime_type || '').includes('mpeg')) {
|
||||||
|
playEncoded(d.audio, d.mime_type || 'audio/mpeg');
|
||||||
|
} else {
|
||||||
|
playPcm(d.audio, d.sample_rate || TARGET_SAMPLE_RATE);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'tts_failed':
|
||||||
|
currentTtsFailedRef.current = true;
|
||||||
|
currentTtsMsgRef.current = d.message || 'ElevenLabs TTS недоступен.';
|
||||||
|
addMsg('system', currentTtsMsgRef.current);
|
||||||
|
break;
|
||||||
|
case 'assistant_done': {
|
||||||
|
const fell = browserFallback();
|
||||||
|
if (!fell) {
|
||||||
|
scheduleUnmute();
|
||||||
|
setStatusText(currentTtsFailedRef.current ? 'tts fallback' : 'live');
|
||||||
|
setStatusKind(currentTtsFailedRef.current ? 'busy' : 'live');
|
||||||
|
setPartialText(currentTtsFailedRef.current ? currentTtsMsgRef.current : 'Слушаю...');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'reset_done':
|
||||||
|
setPartialText('Контекст очищен.');
|
||||||
|
if (isRunningRef.current && socketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
socketRef.current.send(JSON.stringify({ type: 'start_greeting' }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
lastErrRef.current = d.message || 'Ошибка.';
|
||||||
|
setPartialText(lastErrRef.current);
|
||||||
|
addMsg('system', lastErrRef.current);
|
||||||
|
setStatusText('error');
|
||||||
|
setStatusKind('error');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [addMsg, updateMsg, playPcm, playEncoded, browserFallback, scheduleUnmute]);
|
||||||
|
|
||||||
|
// ── Stop ─────────────────────────────────────────────────────────────────────
|
||||||
|
const stopCall = useCallback(async (msg = 'Звонок завершён.') => {
|
||||||
|
if (isStoppingRef.current) return;
|
||||||
|
isStoppingRef.current = true;
|
||||||
|
|
||||||
|
setIsRunning(false);
|
||||||
|
setIsMuted(false);
|
||||||
|
setLevelPct(0);
|
||||||
|
|
||||||
|
processorRef.current?.disconnect();
|
||||||
|
if (processorRef.current) processorRef.current.onaudioprocess = null;
|
||||||
|
sourceRef.current?.disconnect();
|
||||||
|
streamRef.current?.getTracks().forEach(t => t.stop());
|
||||||
|
if (socketRef.current?.readyState === WebSocket.OPEN) socketRef.current.close();
|
||||||
|
const ac = audioCtxRef.current;
|
||||||
|
if (ac && ac.state !== 'closed') await ac.close();
|
||||||
|
|
||||||
|
processorRef.current = null;
|
||||||
|
sourceRef.current = null;
|
||||||
|
streamRef.current = null;
|
||||||
|
audioCtxRef.current = null;
|
||||||
|
socketRef.current = null;
|
||||||
|
|
||||||
|
currentMsgIdRef.current = null;
|
||||||
|
currentTextRef.current = '';
|
||||||
|
currentChunksRef.current = 0;
|
||||||
|
currentTtsFailedRef.current = false;
|
||||||
|
currentTtsMsgRef.current = '';
|
||||||
|
playbackCursorRef.current = 0;
|
||||||
|
pendingEncodedRef.current = 0;
|
||||||
|
activeAudiosRef.current.forEach(a => { a.pause(); a.src = ''; });
|
||||||
|
activeAudiosRef.current.clear();
|
||||||
|
chainRef.current = Promise.resolve();
|
||||||
|
|
||||||
|
setPartialText(msg);
|
||||||
|
drawIdle();
|
||||||
|
isStoppingRef.current = false;
|
||||||
|
}, [drawIdle]);
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
const startCall = useCallback(async () => {
|
||||||
|
lastErrRef.current = '';
|
||||||
|
isStoppingRef.current = false;
|
||||||
|
setStatusText('connecting');
|
||||||
|
setStatusKind('busy');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${proto}//${window.location.host}/voice-ai/ws`);
|
||||||
|
socketRef.current = ws;
|
||||||
|
|
||||||
|
ws.addEventListener('message', handleMsg);
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
if (isRunningRef.current) stopCall(lastErrRef.current || 'Звонок остановлен.');
|
||||||
|
});
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
lastErrRef.current = 'Ошибка WebSocket.';
|
||||||
|
setStatusText('ws error');
|
||||||
|
setStatusKind('error');
|
||||||
|
setPartialText(lastErrRef.current);
|
||||||
|
addMsg('system', lastErrRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((res, rej) => {
|
||||||
|
ws.addEventListener('open', () => res(), { once: true });
|
||||||
|
ws.addEventListener('error', () => rej(new Error('Нет подключения к серверу.')), { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for server "ready"
|
||||||
|
await new Promise<void>((res, rej) => {
|
||||||
|
const t = window.setTimeout(() => { off(); rej(new Error('Сервер не ответил.')); }, 6000);
|
||||||
|
const off = () => { clearTimeout(t); ws.removeEventListener('message', onM); ws.removeEventListener('close', onC); };
|
||||||
|
const onM = (e: MessageEvent) => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (d.type === 'ready') { off(); res(); }
|
||||||
|
else if (d.type === 'error') { off(); rej(new Error(d.message || 'Ошибка.')); }
|
||||||
|
};
|
||||||
|
const onC = () => { off(); rej(new Error('WebSocket закрыт.')); };
|
||||||
|
ws.addEventListener('message', onM);
|
||||||
|
ws.addEventListener('close', onC);
|
||||||
|
});
|
||||||
|
|
||||||
|
const media = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 },
|
||||||
|
});
|
||||||
|
streamRef.current = media;
|
||||||
|
|
||||||
|
const ac = new AudioContext();
|
||||||
|
await ac.resume();
|
||||||
|
audioCtxRef.current = ac;
|
||||||
|
|
||||||
|
const src = ac.createMediaStreamSource(media);
|
||||||
|
sourceRef.current = src;
|
||||||
|
const proc = ac.createScriptProcessor(4096, 1, 1);
|
||||||
|
processorRef.current = proc;
|
||||||
|
|
||||||
|
proc.onaudioprocess = ev => {
|
||||||
|
const buf = ev.inputBuffer.getChannelData(0);
|
||||||
|
drawWave(buf);
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
|
||||||
|
setLevelPct(Math.min(100, Math.round(Math.sqrt(sum / buf.length) * 260)));
|
||||||
|
|
||||||
|
if (!isRunningRef.current || isMutedRef.current || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(floatTo16Bit(downsample(buf, ac.sampleRate, TARGET_SAMPLE_RATE)));
|
||||||
|
};
|
||||||
|
|
||||||
|
src.connect(proc);
|
||||||
|
proc.connect(ac.destination);
|
||||||
|
|
||||||
|
setIsRunning(true);
|
||||||
|
setStatusText('live');
|
||||||
|
setStatusKind('live');
|
||||||
|
setPartialText('Слушаю...');
|
||||||
|
ws.send(JSON.stringify({ type: 'start_greeting' }));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Не удалось начать звонок.';
|
||||||
|
lastErrRef.current = msg;
|
||||||
|
processorRef.current?.disconnect();
|
||||||
|
sourceRef.current?.disconnect();
|
||||||
|
streamRef.current?.getTracks().forEach(t => t.stop());
|
||||||
|
if (socketRef.current?.readyState === WebSocket.OPEN) socketRef.current.close();
|
||||||
|
const ac = audioCtxRef.current;
|
||||||
|
if (ac && ac.state !== 'closed') await ac.close();
|
||||||
|
processorRef.current = null; sourceRef.current = null;
|
||||||
|
streamRef.current = null; audioCtxRef.current = null; socketRef.current = null;
|
||||||
|
setIsRunning(false);
|
||||||
|
setStatusText('error');
|
||||||
|
setStatusKind('error');
|
||||||
|
setPartialText(msg);
|
||||||
|
addMsg('system', msg);
|
||||||
|
}
|
||||||
|
}, [handleMsg, stopCall, drawWave, addMsg]);
|
||||||
|
|
||||||
|
const resetSession = useCallback(() => {
|
||||||
|
setMessages([]);
|
||||||
|
currentMsgIdRef.current = null;
|
||||||
|
currentTextRef.current = '';
|
||||||
|
currentChunksRef.current = 0;
|
||||||
|
setPartialText('Контекст очищен.');
|
||||||
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
socketRef.current.send(JSON.stringify({ type: 'reset' }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => { stopCall(); }, [stopCall]);
|
||||||
|
|
||||||
|
// ── Status dot colour ────────────────────────────────────────────────────────
|
||||||
|
const dotColor: Record<string, string> = {
|
||||||
|
live: 'bg-emerald-500',
|
||||||
|
busy: 'bg-amber-500',
|
||||||
|
error: 'bg-rose-500',
|
||||||
|
connecting: 'bg-sky-400',
|
||||||
|
};
|
||||||
|
const dot = dotColor[statusKind] || 'bg-slate-400';
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">AI Голос</h2>
|
||||||
|
<p className="text-slate-500 text-sm italic">Голосовой AI-оператор Айнур — QazaqGasAimaq</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
|
||||||
|
|
||||||
|
{/* ── Left: controls ───────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Status pill */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4 shadow-sm flex items-center gap-3">
|
||||||
|
<div className={cn('w-2.5 h-2.5 rounded-full', statusKind === 'live' && 'animate-pulse', dot)} />
|
||||||
|
<span className="text-sm font-bold text-slate-700 uppercase tracking-widest truncate">
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waveform canvas */}
|
||||||
|
<div className="bg-slate-900 rounded-2xl overflow-hidden border border-slate-800 shadow-xl">
|
||||||
|
<canvas ref={canvasRef} width={600} height={100} className="w-full h-auto block" />
|
||||||
|
<div className="px-4 pb-3 pt-2 flex items-center gap-3">
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest shrink-0">Mic</span>
|
||||||
|
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-75"
|
||||||
|
style={{
|
||||||
|
width: `${levelPct}%`,
|
||||||
|
backgroundColor: isMuted ? '#ef4444' : '#22c55e',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest shrink-0">
|
||||||
|
{isMuted ? 'muted' : 'open'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={startCall}
|
||||||
|
disabled={isRunning}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all active:scale-95',
|
||||||
|
isRunning
|
||||||
|
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||||
|
: 'bg-emerald-600 text-white hover:bg-emerald-700 shadow-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PhoneCall className="w-4 h-4" />
|
||||||
|
Начать
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => stopCall()}
|
||||||
|
disabled={!isRunning}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all active:scale-95',
|
||||||
|
!isRunning
|
||||||
|
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||||
|
: 'bg-rose-600 text-white hover:bg-rose-700 shadow-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PhoneOff className="w-4 h-4" />
|
||||||
|
Стоп
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={resetSession}
|
||||||
|
disabled={!isRunning}
|
||||||
|
title="Очистить контекст"
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 rounded-xl text-sm font-bold transition-all active:scale-95',
|
||||||
|
!isRunning
|
||||||
|
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||||
|
: 'bg-slate-700 text-white hover:bg-slate-800 shadow-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info card */}
|
||||||
|
<div className="bg-indigo-50 border border-indigo-100 rounded-2xl p-4 text-xs text-indigo-700 space-y-1">
|
||||||
|
<div className="font-bold uppercase tracking-widest text-[10px] text-indigo-400 mb-2">Инструкция</div>
|
||||||
|
<p>Нажмите <b>Начать</b> — Айнур поздоровается и попросит выбрать язык.</p>
|
||||||
|
<p>Говорите в микрофон. Пока Айнур отвечает, микрофон заглушается автоматически.</p>
|
||||||
|
<p><b>Reset</b> сбрасывает контекст разговора, не прерывая сессию.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right: transcript ──────────────────────────────────────────────── */}
|
||||||
|
<div className="lg:col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm flex flex-col overflow-hidden min-h-[400px]">
|
||||||
|
<div className="p-4 border-b border-slate-100 flex items-center gap-2 shrink-0">
|
||||||
|
<Mic className="w-4 h-4 text-indigo-500" />
|
||||||
|
<h3 className="font-bold text-slate-900 text-sm tracking-tight">Транскрипт</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{/* Partial / status line */}
|
||||||
|
<div className="text-xs text-slate-400 italic text-center sticky top-0 bg-white/90 py-1 rounded-lg">
|
||||||
|
{partialText}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{messages.map(m => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
m.role === 'user' ? 'justify-end' : m.role === 'system' ? 'justify-center' : 'justify-start',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.role === 'system' ? (
|
||||||
|
<span className="text-[11px] text-slate-400 italic px-3 py-1 bg-slate-50 rounded-full border border-slate-100">
|
||||||
|
{m.text}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[80%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed',
|
||||||
|
m.role === 'user'
|
||||||
|
? 'bg-indigo-600 text-white rounded-br-md'
|
||||||
|
: 'bg-slate-100 text-slate-800 rounded-bl-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.role === 'assistant' && (
|
||||||
|
<div className="text-[10px] font-bold text-indigo-500 uppercase tracking-widest mb-1">
|
||||||
|
Айнур
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{m.text || <span className="opacity-40 italic">...</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
UserPlus,
|
||||||
|
Settings,
|
||||||
|
Search,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Webhook,
|
||||||
|
Bot,
|
||||||
|
ChevronRight,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
Database,
|
||||||
|
Type,
|
||||||
|
FileText,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Globe,
|
||||||
|
Trash2,
|
||||||
|
MessageCircle,
|
||||||
|
Mail,
|
||||||
|
Zap,
|
||||||
|
Send,
|
||||||
|
MoreVertical,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { UserRole } from '../../types';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { useToast } from '../../components/ui/Toast';
|
||||||
|
import { apiRequest, BackendQueue, BackendUser, roleFromBackend, roleToBackend } from '../../lib/api';
|
||||||
|
|
||||||
|
type AdminTab = 'users' | 'queues' | 'channels' | 'ai';
|
||||||
|
|
||||||
|
export const AdminView: React.FC<{ initialTab?: AdminTab }> = ({ initialTab = 'users' }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<AdminTab>(initialTab);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showAddUser, setShowAddUser] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [users, setUsers] = useState<BackendUser[]>([]);
|
||||||
|
const [queues, setQueues] = useState<BackendQueue[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const loadAdminData = React.useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
apiRequest<BackendUser[]>('auth', 'users'),
|
||||||
|
apiRequest<BackendQueue[]>('routing', 'queues'),
|
||||||
|
]);
|
||||||
|
if (results[0].status === 'fulfilled') setUsers(results[0].value);
|
||||||
|
if (results[1].status === 'fulfilled') setQueues(results[1].value);
|
||||||
|
const rejected = results.find((result) => result.status === 'rejected');
|
||||||
|
if (rejected) {
|
||||||
|
setLoadError(rejected.reason instanceof Error ? rejected.reason.message : 'Backend недоступен');
|
||||||
|
if (results[0].status === 'rejected') setUsers([]);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadAdminData();
|
||||||
|
}, [loadAdminData]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setActiveTab(initialTab);
|
||||||
|
}, [initialTab]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
Promise.allSettled([apiRequest('auth', 'health'), apiRequest('routing', 'health'), apiRequest('kb', 'health')]).finally(() => {
|
||||||
|
setIsSaving(false);
|
||||||
|
showToast('success', 'Глобальная синхронизация', 'Все системные параметры успешно синхронизированы с ядром.');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const [newUser, setNewUser] = useState({ name: '', username: '', password: 'op12345', role: UserRole.OPERATOR });
|
||||||
|
const [errors, setErrors] = useState<{name?: string, username?: string, password?: string}>({});
|
||||||
|
|
||||||
|
const handleCreateUser = async () => {
|
||||||
|
const newErrors: any = {};
|
||||||
|
if (!newUser.name) newErrors.name = 'Требуется имя пользователя';
|
||||||
|
if (!newUser.username || newUser.username.length < 3) newErrors.username = 'Логин должен быть не короче 3 символов';
|
||||||
|
if (!newUser.password || newUser.password.length < 6) newErrors.password = 'Пароль должен быть не короче 6 символов';
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
showToast('error', 'Ошибка валидации', 'Пожалуйста, проверьте вводимые данные.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await apiRequest<BackendUser>('auth', 'users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: newUser.username,
|
||||||
|
password: newUser.password,
|
||||||
|
full_name: newUser.name,
|
||||||
|
role: roleToBackend(newUser.role),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
setShowAddUser(false);
|
||||||
|
showToast('success', 'Синхронизация завершена', `Учетная запись ${newUser.name} создана в auth-service.`);
|
||||||
|
setNewUser({ name: '', username: '', password: 'op12345', role: UserRole.OPERATOR });
|
||||||
|
setErrors({});
|
||||||
|
await loadAdminData();
|
||||||
|
} catch (err) {
|
||||||
|
setIsLoading(false);
|
||||||
|
showToast('error', 'Auth-service отклонил создание', err instanceof Error ? err.message : 'Backend error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">Панель управления</h2>
|
||||||
|
<p className="text-slate-500 text-sm italic">Системное администрирование и управление ресурсами</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
|
{isSaving ? (
|
||||||
|
<button disabled className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-6 py-2.5 bg-slate-100 text-slate-400 rounded-xl text-sm font-bold border border-slate-200">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Синхронизация...
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleSave} className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-xl text-sm font-bold hover:bg-indigo-700 transition-all shadow-md active:scale-95">
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Сохранить конфигурацию
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadError && (
|
||||||
|
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||||
|
Часть админ-данных недоступна: {loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
{/* Navigation Sidebar */}
|
||||||
|
<div className="lg:col-span-3 space-y-2">
|
||||||
|
<AdminSidebarItem
|
||||||
|
icon={Users}
|
||||||
|
label="Пользователи"
|
||||||
|
description="Управление аккаунтами и ролями"
|
||||||
|
active={activeTab === 'users'}
|
||||||
|
onClick={() => setActiveTab('users')}
|
||||||
|
/>
|
||||||
|
<AdminSidebarItem
|
||||||
|
icon={Settings}
|
||||||
|
label="Очереди обслуживания"
|
||||||
|
description="Время ожидания и емкость"
|
||||||
|
active={activeTab === 'queues'}
|
||||||
|
onClick={() => setActiveTab('queues')}
|
||||||
|
/>
|
||||||
|
<AdminSidebarItem
|
||||||
|
icon={Webhook}
|
||||||
|
label="Омни-каналы"
|
||||||
|
description="WhatsApp, TG, Email"
|
||||||
|
active={activeTab === 'channels'}
|
||||||
|
onClick={() => setActiveTab('channels')}
|
||||||
|
/>
|
||||||
|
<AdminSidebarItem
|
||||||
|
icon={Bot}
|
||||||
|
label="ИИ и автоматизация"
|
||||||
|
description="Настройка LLM и ботов"
|
||||||
|
active={activeTab === 'ai'}
|
||||||
|
onClick={() => setActiveTab('ai')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="lg:col-span-9">
|
||||||
|
{activeTab === 'users' && <UserManagementSection users={users} isLoading={isLoading} onAddClick={() => setShowAddUser(true)} onRefresh={loadAdminData} />}
|
||||||
|
{activeTab === 'ai' && <AIConfigSection />}
|
||||||
|
{activeTab === 'queues' && <QueueSection queues={queues} />}
|
||||||
|
{activeTab === 'channels' && <ChannelsSection />}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add User Modal Mockup */}
|
||||||
|
{showAddUser && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-md" onClick={() => setShowAddUser(false)}></div>
|
||||||
|
<div className="bg-white rounded-3xl w-full max-w-xl relative z-10 shadow-2xl overflow-hidden border border-white/20">
|
||||||
|
<div className="p-8 border-b border-slate-100">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 italic tracking-tight">Новый сотрудник</h3>
|
||||||
|
<p className="text-slate-500 text-sm">Заполните данные для создания аккаунта нового оператора.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-8 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={cn("text-[10px] font-bold uppercase tracking-widest", errors.name ? "text-rose-500" : "text-slate-400")}>Полное имя</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newUser.name}
|
||||||
|
onChange={(e) => setNewUser({...newUser, name: e.target.value})}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-4 py-2.5 rounded-xl border text-sm italic transition-all",
|
||||||
|
errors.name ? "border-rose-300 focus:ring-rose-500/5 focus:border-rose-500" : "border-slate-200 focus:ring-indigo-500/5 focus:border-indigo-500"
|
||||||
|
)}
|
||||||
|
placeholder="Введите ФИО..."
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-[10px] text-rose-500 font-bold italic">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={cn("text-[10px] font-bold uppercase tracking-widest", errors.username ? "text-rose-500" : "text-slate-400")}>Логин</label>
|
||||||
|
<input
|
||||||
|
value={newUser.username}
|
||||||
|
onChange={(e) => setNewUser({...newUser, username: e.target.value})}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-4 py-2.5 rounded-xl border text-sm italic transition-all",
|
||||||
|
errors.username ? "border-rose-300 focus:ring-rose-500/5 focus:border-rose-500" : "border-slate-200 focus:ring-indigo-500/5 focus:border-indigo-500"
|
||||||
|
)}
|
||||||
|
placeholder="agent42"
|
||||||
|
/>
|
||||||
|
{errors.username && <p className="text-[10px] text-rose-500 font-bold italic">{errors.username}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={cn("text-[10px] font-bold uppercase tracking-widest", errors.password ? "text-rose-500" : "text-slate-400")}>Временный пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newUser.password}
|
||||||
|
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-4 py-2.5 rounded-xl border text-sm italic transition-all",
|
||||||
|
errors.password ? "border-rose-300 focus:ring-rose-500/5 focus:border-rose-500" : "border-slate-200 focus:ring-indigo-500/5 focus:border-indigo-500"
|
||||||
|
)}
|
||||||
|
placeholder="минимум 6 символов"
|
||||||
|
/>
|
||||||
|
{errors.password && <p className="text-[10px] text-rose-500 font-bold italic">{errors.password}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Назначить роль</label>
|
||||||
|
<select
|
||||||
|
value={newUser.role}
|
||||||
|
onChange={(e) => setNewUser({...newUser, role: e.target.value as UserRole})}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:ring-4 focus:ring-indigo-500/5 focus:border-indigo-500 text-sm bg-white"
|
||||||
|
>
|
||||||
|
<option value={UserRole.OPERATOR}>Оператор</option>
|
||||||
|
<option value={UserRole.SUPERVISOR}>Супервайзер</option>
|
||||||
|
<option value={UserRole.ADMIN}>Администратор</option>
|
||||||
|
<option value={UserRole.ANALYST}>Аналитик</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-8 bg-slate-50 flex gap-4">
|
||||||
|
<button onClick={() => setShowAddUser(false)} disabled={isLoading} className="flex-1 py-3 text-sm font-bold text-slate-500 hover:text-slate-700 transition-colors disabled:opacity-50">Отмена</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateUser}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 py-3 bg-slate-900 text-white rounded-xl text-sm font-bold hover:bg-slate-800 shadow-lg active:scale-95 transition-all flex items-center justify-center gap-2 disabled:bg-slate-400"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Создать аккаунт"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserManagementSection: React.FC<{ users: BackendUser[]; isLoading: boolean; onAddClick: () => void; onRefresh: () => void }> = ({ users, isLoading, onAddClick, onRefresh }) => (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
<div className="p-4 md:p-6 border-b border-slate-100 flex flex-col md:flex-row items-start md:items-center justify-between gap-4 bg-slate-50/30">
|
||||||
|
<div className="relative flex-1 w-full max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input type="text" placeholder="Поиск по аккаунтам..." className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-xs focus:ring-4 focus:ring-indigo-500/5 focus:border-indigo-500 transition-all shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 border border-slate-200 bg-white text-slate-600 rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-slate-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Clock className="w-4 h-4" />}
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onAddClick}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 text-white rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-indigo-700 transition-all shadow-md active:scale-95"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
Добавить аккаунт
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto text-sm">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
|
||||||
|
<th className="px-6 py-4">Личность</th>
|
||||||
|
<th className="px-6 py-4">Уровень доступа</th>
|
||||||
|
<th className="px-6 py-4">Статус авторизации</th>
|
||||||
|
<th className="px-6 py-4">Последняя синхронизация</th>
|
||||||
|
<th className="px-6 py-4 text-right">Настройки</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50">
|
||||||
|
{users.map(user => {
|
||||||
|
const role = roleFromBackend(user.role);
|
||||||
|
return (
|
||||||
|
<tr key={user.user_id} className="hover:bg-slate-50/50 group transition-colors italic">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-slate-900">{user.full_name}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 lowercase tracking-tight">{user.username}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={cn(
|
||||||
|
"px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-widest",
|
||||||
|
role === UserRole.ADMIN ? "bg-indigo-50 text-indigo-700" : "bg-slate-100 text-slate-600"
|
||||||
|
)}>
|
||||||
|
{role === UserRole.ADMIN ? 'АДМИНИСТРАТОР' : role === UserRole.SUPERVISOR ? 'СУПЕРВАЙЗЕР' : role === UserRole.ANALYST ? 'АНАЛИТИК' : 'ОПЕРАТОР'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="flex items-center gap-1 text-emerald-600 font-bold text-[10px] uppercase tracking-widest">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
Активен
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-slate-400 font-mono text-[10px] italic">
|
||||||
|
auth-service
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button className="text-[10px] font-bold text-slate-400 hover:text-indigo-600 transition-colors uppercase tracking-widest opacity-0 group-hover:opacity-100">
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)})}
|
||||||
|
{!users.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">
|
||||||
|
Auth-service пока не вернул пользователей.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AIConfigSection: React.FC = () => {
|
||||||
|
const [aiHealth, setAiHealth] = React.useState<{ status?: string; service?: string; version?: string } | null>(null);
|
||||||
|
const [kbHealth, setKbHealth] = React.useState<{ status?: string; service?: string; version?: string } | null>(null);
|
||||||
|
const [ttsConfig, setTtsConfig] = React.useState<{ config?: { provider?: string }; source?: string } | null>(null);
|
||||||
|
const [kbArticles, setKbArticles] = React.useState<Array<{ article_id: string; title: string; updated_at: string }>>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
Promise.allSettled([
|
||||||
|
apiRequest<{ status?: string; service?: string; version?: string }>('ai', 'health'),
|
||||||
|
apiRequest<{ status?: string; service?: string; version?: string }>('kb', 'health'),
|
||||||
|
apiRequest<{ config?: { provider?: string }; source?: string }>('ai', 'ai/voice/config/tts'),
|
||||||
|
apiRequest<Array<{ article_id: string; title: string; updated_at: string }>>('kb', 'knowledge/search?q=&limit=50'),
|
||||||
|
]).then(([aiResult, kbResult, ttsResult, articlesResult]) => {
|
||||||
|
if (aiResult.status === 'fulfilled') setAiHealth(aiResult.value);
|
||||||
|
if (kbResult.status === 'fulfilled') setKbHealth(kbResult.value);
|
||||||
|
if (ttsResult.status === 'fulfilled') setTtsConfig(ttsResult.value);
|
||||||
|
if (articlesResult.status === 'fulfilled') setKbArticles(articlesResult.value);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const provider = ttsConfig?.config?.provider || 'not configured';
|
||||||
|
const kbLastSync = kbArticles[0]?.updated_at ? new Date(kbArticles[0].updated_at).toLocaleString('ru-RU') : 'нет статей';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-400">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<div className="w-12 h-12 bg-indigo-900 rounded-2xl flex items-center justify-center text-white">
|
||||||
|
<Bot className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-slate-900 tracking-tight">Оптимизация ИИ-движка</h3>
|
||||||
|
<p className="text-slate-500 text-xs italic">Конфигурация нейросетевых моделей и алгоритмов автоматизации.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block">Провайдер голосового AI/TTS</label>
|
||||||
|
<select className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium">
|
||||||
|
<option>{provider}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block">Статус AI Orchestrator</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input type="range" value={aiHealth?.status === 'ok' ? 100 : 0} readOnly className="flex-1 h-1.5 bg-slate-200 rounded-full appearance-none accent-indigo-600" />
|
||||||
|
<span className="text-sm font-mono font-bold text-indigo-600">{aiHealth?.status || 'down'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Knowledge Base Sub-section */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Database className="w-5 h-5 text-indigo-500" />
|
||||||
|
<h3 className="font-bold text-slate-900 italic tracking-tight">Корпоративная база знаний</h3>
|
||||||
|
</div>
|
||||||
|
<button className="text-[10px] font-bold text-indigo-600 uppercase tracking-widest px-3 py-1.5 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors">Синхронизировать</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<KnowledgeSourceItem icon={Globe} title={kbHealth?.service || 'kb-service'} type="Service Health" lastSync={kbHealth?.status || 'down'} items={kbArticles.length} />
|
||||||
|
<KnowledgeSourceItem icon={FileText} title="Статьи базы знаний" type="Database" lastSync={kbLastSync} items={kbArticles.length} />
|
||||||
|
<KnowledgeSourceItem icon={LinkIcon} title={aiHealth?.service || 'ai-orchestrator-service'} type="Service Health" lastSync={aiHealth?.status || 'down'} items={aiHealth?.status === 'ok' ? 1 : 0} />
|
||||||
|
<KnowledgeSourceItem icon={Type} title="Voice TTS config" type={ttsConfig?.source || 'config'} lastSync={provider} items={ttsConfig ? 1 : 0} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const KnowledgeSourceItem: React.FC<{ icon: any, title: string, type: string, lastSync: string, items: number }> = ({ icon: Icon, title, type, lastSync, items }) => (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50/50 rounded-2xl border border-slate-100 group hover:border-indigo-200 hover:bg-white transition-all cursor-pointer">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center text-slate-400 group-hover:text-indigo-600 group-hover:border-indigo-100 transition-colors">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-slate-900 italic leading-tight">{title}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">{type} • {items} записей</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] font-bold text-emerald-600 uppercase tracking-widest mb-0.5">Верифицировано</div>
|
||||||
|
<div className="text-[9px] text-slate-400 italic">Синхр: {lastSync}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const QueueSection: React.FC<{ queues: BackendQueue[] }> = ({ queues }) => (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="font-bold text-slate-900 tracking-tight flex items-center gap-2 text-lg">
|
||||||
|
<Settings className="w-5 h-5 text-indigo-500" />
|
||||||
|
Емкость очередей обслуживания
|
||||||
|
</h3>
|
||||||
|
<button className="text-[10px] font-bold text-indigo-600 uppercase tracking-widest border border-indigo-100 px-3 py-1.5 rounded-lg hover:bg-indigo-50 transition-colors">Создать очередь</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{queues.length ? queues.map((queue) => {
|
||||||
|
const maxSla = Math.max(...queue.rules.map((rule) => Number(rule.sla_seconds || 0)), 0);
|
||||||
|
const priority = maxSla && maxSla <= 20 ? 'URGENT' : maxSla && maxSla <= 45 ? 'HIGH' : 'NORMAL';
|
||||||
|
const color = priority === 'URGENT' ? 'rose' : priority === 'HIGH' ? 'indigo' : 'emerald';
|
||||||
|
return (
|
||||||
|
<QueueRow
|
||||||
|
key={queue.queue_id}
|
||||||
|
name={queue.name}
|
||||||
|
capacity={queue.rules.length || 0}
|
||||||
|
priority={priority}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<QueueRow name="Очереди не созданы" capacity={0} priority="NORMAL" color="emerald" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChannelsSection: React.FC = () => {
|
||||||
|
const [statusByService, setStatusByService] = React.useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const services = ['whatsapp', 'telegram', 'email', 'webchat'] as const;
|
||||||
|
Promise.allSettled(services.map((service) => apiRequest<{ status?: string }>(service, 'health'))).then((results) => {
|
||||||
|
setStatusByService(
|
||||||
|
Object.fromEntries(
|
||||||
|
services.map((service, index) => [
|
||||||
|
service,
|
||||||
|
results[index].status === 'fulfilled' ? results[index].value.status || 'ok' : 'down',
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-slate-100">
|
||||||
|
<h3 className="font-bold text-slate-900 italic tracking-tight text-lg">Подключенные каналы</h3>
|
||||||
|
<p className="text-xs text-slate-500">Интеграции для приема входящих запросов от клиентов.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<ChannelCard provider="WhatsApp" type={`whatsapp-service: ${statusByService.whatsapp || 'checking'}`} icon={MessageCircle} color="emerald" active={statusByService.whatsapp === 'ok'} />
|
||||||
|
<ChannelCard provider="Telegram" type={`telegram-service: ${statusByService.telegram || 'checking'}`} icon={Send} color="sky" active={statusByService.telegram === 'ok'} />
|
||||||
|
<ChannelCard provider="Корпоративная почта" type={`email-service: ${statusByService.email || 'checking'}`} icon={Mail} color="slate" active={statusByService.email === 'ok'} />
|
||||||
|
<ChannelCard provider="Онлайн чат" type={`webchat-service: ${statusByService.webchat || 'checking'}`} icon={Zap} color="indigo" active={statusByService.webchat === 'ok'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChannelCard: React.FC<{ provider: string, type: string, icon: any, color: string, active?: boolean }> = ({ provider, type, icon: Icon, color, active }) => (
|
||||||
|
<div className="p-5 bg-white border border-slate-200 rounded-3xl group hover:border-indigo-200 transition-all relative overflow-hidden shadow-sm hover:shadow-lg hover:shadow-indigo-500/5">
|
||||||
|
<div className="flex items-start justify-between relative z-10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={cn("w-12 h-12 rounded-2xl flex items-center justify-center transition-colors shadow-inner", `bg-${color}-50 text-${color}-600 border border-${color}-100`)}>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-slate-900 tracking-tight">{provider}</h4>
|
||||||
|
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">{type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="p-2 text-slate-300 hover:text-slate-600 bg-slate-50 rounded-xl">
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center justify-between relative z-10">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className={cn("w-1.5 h-1.5 rounded-full", active ? "bg-emerald-500 animate-pulse" : "bg-slate-300")}></div>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">{active ? "Подключено" : "Неактивен"}</span>
|
||||||
|
</div>
|
||||||
|
<button className="text-[10px] font-bold text-indigo-600 uppercase tracking-widest hover:underline transition-all">Настроить</button>
|
||||||
|
</div>
|
||||||
|
<div className={cn("absolute bottom-0 right-0 -mb-8 -mr-8 w-24 h-24 rounded-full blur-[40px] opacity-10 group-hover:opacity-20 transition-opacity", `bg-${color}-500`)}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const QueueRow: React.FC<{ name: string, capacity: number, priority: string, color: string }> = ({ name, capacity, priority, color }) => (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50/50 rounded-2xl border border-slate-100 group hover:border-indigo-200 hover:bg-white transition-all cursor-pointer shadow-sm shadow-transparent hover:shadow-indigo-500/5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={cn("px-2 py-1 rounded-lg text-[9px] font-bold uppercase tracking-widest", `bg-${color}-50 text-${color}-700 border border-${color}-100`)}>
|
||||||
|
{priority === 'URGENT' ? 'СРОЧНО' : priority === 'HIGH' ? 'ВЫСОКИЙ' : 'НОРМА'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-slate-900 italic leading-tight">{name}</div>
|
||||||
|
<div className="text-[10px] text-slate-500 font-medium">{capacity} слотов доступно</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AdminSidebarItem: React.FC<{ icon: any, label: string, description: string, active?: boolean, onClick: () => void }> = ({ icon: Icon, label, description, active, onClick }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-4 px-4 py-4 rounded-2xl text-left transition-all border",
|
||||||
|
active
|
||||||
|
? "bg-white border-slate-200 text-indigo-700 shadow-md shadow-slate-200/50 scale-[1.02] z-10"
|
||||||
|
: "bg-transparent border-transparent text-slate-500 hover:bg-white/50 hover:border-slate-100 hover:text-slate-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0 border transition-colors duration-300",
|
||||||
|
active ? "bg-indigo-600 border-indigo-700 text-white" : "bg-slate-100 border-slate-200 text-slate-400"
|
||||||
|
)}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-bold truncate leading-tight">{label}</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-medium truncate uppercase tracking-widest transition-opacity group-hover:opacity-100">{description}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
Cell,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
AreaChart,
|
||||||
|
Area
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Download,
|
||||||
|
Filter,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
MessageSquare,
|
||||||
|
Award,
|
||||||
|
ChevronRight,
|
||||||
|
MoreVertical,
|
||||||
|
Search,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
User,
|
||||||
|
LayoutGrid,
|
||||||
|
History,
|
||||||
|
ExternalLink
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { ChannelType } from '../../types';
|
||||||
|
|
||||||
|
import { useToast } from '../../components/ui/Toast';
|
||||||
|
import { LoadingState } from '../../components/ui/FeedbackStates';
|
||||||
|
import {
|
||||||
|
apiRequest,
|
||||||
|
BackendAgentOverview,
|
||||||
|
BackendDrilldown,
|
||||||
|
BackendKpi,
|
||||||
|
BackendOmnichannelInsights,
|
||||||
|
BackendQueueOverview,
|
||||||
|
BackendTimeseries,
|
||||||
|
channelFromBackend,
|
||||||
|
} from '../../lib/api';
|
||||||
|
|
||||||
|
const secondsToClock = (seconds?: number | null) => {
|
||||||
|
const safe = Math.max(0, Math.round(Number(seconds || 0)));
|
||||||
|
const minutes = Math.floor(safe / 60);
|
||||||
|
return `${String(minutes).padStart(2, '0')}:${String(safe % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalystView: React.FC = () => {
|
||||||
|
const [dateRange, setDateRange] = useState('7д');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [kpi, setKpi] = useState<BackendKpi | null>(null);
|
||||||
|
const [timeseries, setTimeseries] = useState<BackendTimeseries | null>(null);
|
||||||
|
const [drilldown, setDrilldown] = useState<BackendDrilldown | null>(null);
|
||||||
|
const [agentOverview, setAgentOverview] = useState<BackendAgentOverview | null>(null);
|
||||||
|
const [queueOverview, setQueueOverview] = useState<BackendQueueOverview | null>(null);
|
||||||
|
const [insights, setInsights] = useState<BackendOmnichannelInsights | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const windowParams = React.useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now);
|
||||||
|
const days = dateRange === '24ч' ? 1 : dateRange === '30д' ? 30 : dateRange === '90д' ? 90 : 7;
|
||||||
|
from.setDate(now.getDate() - days);
|
||||||
|
return {
|
||||||
|
from_ts: from.toISOString(),
|
||||||
|
to_ts: now.toISOString(),
|
||||||
|
};
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
const loadAnalytics = React.useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
const query = new URLSearchParams(windowParams);
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
apiRequest<BackendKpi>('reporting', `reports/kpi?${query.toString()}`),
|
||||||
|
apiRequest<BackendTimeseries>('reporting', `reports/timeseries?${query.toString()}&metric=total&interval=day`),
|
||||||
|
apiRequest<BackendDrilldown>('interaction', `interactions/drilldown?${query.toString()}&limit=25`),
|
||||||
|
apiRequest<BackendAgentOverview>('reporting', `reports/agents/overview?${query.toString()}&limit=8`),
|
||||||
|
apiRequest<BackendQueueOverview>('reporting', `reports/queues/overview?${query.toString()}&limit=8`),
|
||||||
|
apiRequest<BackendOmnichannelInsights>('reporting', `reports/omnichannel/insights?${query.toString()}`),
|
||||||
|
]);
|
||||||
|
if (results[0].status === 'fulfilled') setKpi(results[0].value);
|
||||||
|
if (results[1].status === 'fulfilled') setTimeseries(results[1].value);
|
||||||
|
if (results[2].status === 'fulfilled') setDrilldown(results[2].value);
|
||||||
|
if (results[3].status === 'fulfilled') setAgentOverview(results[3].value);
|
||||||
|
if (results[4].status === 'fulfilled') setQueueOverview(results[4].value);
|
||||||
|
if (results[5].status === 'fulfilled') setInsights(results[5].value);
|
||||||
|
const rejected = results.find((result) => result.status === 'rejected');
|
||||||
|
if (rejected) {
|
||||||
|
setLoadError(rejected.reason instanceof Error ? rejected.reason.message : 'Reporting backend недоступен');
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, [windowParams]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadAnalytics();
|
||||||
|
}, [loadAnalytics]);
|
||||||
|
|
||||||
|
const kpiData = React.useMemo(() => {
|
||||||
|
const points = timeseries?.points || [];
|
||||||
|
return points.map((point) => ({
|
||||||
|
name: point.label || new Date(point.ts || point.bucket || Date.now()).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
||||||
|
interactions: Number(point.value || point.sample_size || 0),
|
||||||
|
csat: 0,
|
||||||
|
aht: Number(kpi?.kpi?.AHT || 0),
|
||||||
|
}));
|
||||||
|
}, [kpi, timeseries]);
|
||||||
|
|
||||||
|
const channelData = React.useMemo(() => {
|
||||||
|
const rawBreakdown = kpi?.breakdowns?.by_channel;
|
||||||
|
const breakdown = Array.isArray(rawBreakdown)
|
||||||
|
? rawBreakdown
|
||||||
|
: Object.entries(rawBreakdown || {}).map(([channel, value]) => {
|
||||||
|
const channelValue = value as { total?: number; answered?: number; abandoned?: number };
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
total: Number(channelValue.total || 0),
|
||||||
|
answered: channelValue.answered,
|
||||||
|
abandoned: channelValue.abandoned,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
voice: '#4f46e5',
|
||||||
|
webchat: '#10b981',
|
||||||
|
telegram: '#0ea5e9',
|
||||||
|
whatsapp: '#16a34a',
|
||||||
|
email: '#64748b',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
voice: 'Голос',
|
||||||
|
webchat: 'Веб-чат',
|
||||||
|
telegram: 'Telegram',
|
||||||
|
whatsapp: 'WhatsApp',
|
||||||
|
email: 'Email',
|
||||||
|
};
|
||||||
|
return breakdown.filter((item) => Number(item.total || 0) > 0).map((item) => ({
|
||||||
|
name: labels[item.channel] || item.channel,
|
||||||
|
value: Number(item.total || 0),
|
||||||
|
color: colors[item.channel] || '#64748b',
|
||||||
|
}));
|
||||||
|
}, [kpi]);
|
||||||
|
|
||||||
|
const agentData = React.useMemo(() => {
|
||||||
|
return (agentOverview?.items || []).map((agent) => ({
|
||||||
|
name: agent.agent_id,
|
||||||
|
aht: secondsToClock(agent.avg_handle_seconds),
|
||||||
|
interactions: Number(agent.interactions_total || 0),
|
||||||
|
csat: agent.fcr_rate != null ? Number((agent.fcr_rate / 20).toFixed(1)) : 0,
|
||||||
|
status: agent.current_state || 'OFFLINE',
|
||||||
|
}));
|
||||||
|
}, [agentOverview]);
|
||||||
|
|
||||||
|
const queuePerformance = React.useMemo(() => {
|
||||||
|
return (queueOverview?.items || []).map((queue) => ({
|
||||||
|
name: queue.name || queue.queue_id,
|
||||||
|
value: Math.round(Number(queue.service_level ?? queue.answer_rate ?? 0)),
|
||||||
|
inQueue: Number(queue.in_queue || 0),
|
||||||
|
}));
|
||||||
|
}, [queueOverview]);
|
||||||
|
|
||||||
|
const channelTotal = channelData.reduce((sum, item) => sum + Number(item.value || 0), 0);
|
||||||
|
const historicalInteractions = React.useMemo(() => {
|
||||||
|
const items = drilldown?.items || [];
|
||||||
|
return items.slice(0, 8).map((item) => ({
|
||||||
|
id: item.interaction_id,
|
||||||
|
customer: item.customer_id || item.subject || item.interaction_id,
|
||||||
|
queue: item.queue_id || 'default',
|
||||||
|
duration: item.status,
|
||||||
|
channel: channelFromBackend(item.channel),
|
||||||
|
csat: item.status === 'closed' ? 5 : 3,
|
||||||
|
date: new Date(item.updated_at || item.created_at).toLocaleString('ru-RU', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }),
|
||||||
|
}));
|
||||||
|
}, [drilldown]);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const query = new URLSearchParams(windowParams);
|
||||||
|
window.open(`/proxy/reporting/reports/export?${query.toString()}`, '_blank');
|
||||||
|
showToast('info', 'Экспорт запрошен', 'CSV формируется reporting-service.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRangeChange = (range: string) => {
|
||||||
|
setDateRange(range);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Upper Action Bar */}
|
||||||
|
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 tracking-tight flex items-center gap-2 italic">
|
||||||
|
Панель аналитики
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 text-sm">Глубокий анализ производительности, качества и операционного здоровья</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 w-full lg:w-auto">
|
||||||
|
<div className="flex bg-white rounded-xl border border-slate-200 p-1 shadow-sm">
|
||||||
|
{['24ч', '7д', '30д', '90д'].map(range => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
onClick={() => handleRangeChange(range)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-1.5 rounded-lg text-xs font-bold transition-all uppercase tracking-widest",
|
||||||
|
dateRange === range ? "bg-slate-900 text-white shadow-md" : "text-slate-400 hover:text-slate-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{range}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 border border-slate-200 bg-white rounded-xl text-xs font-bold uppercase tracking-widest text-slate-600 hover:bg-slate-50 transition-all shadow-sm">
|
||||||
|
<Filter className="w-4 h-4 text-indigo-500" />
|
||||||
|
Фильтры
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center gap-2 px-6 py-2 bg-indigo-600 text-white rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-indigo-700 transition-all shadow-md active:scale-95"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Экспорт
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingState message="Пересчет глобальных индексов KPI..." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{loadError && (
|
||||||
|
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||||
|
Часть аналитики недоступна: {loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* KPI Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
label="Общий поток"
|
||||||
|
value={String(kpi?.volume?.total || 0)}
|
||||||
|
subLabel="backend"
|
||||||
|
trend="up"
|
||||||
|
icon={MessageSquare}
|
||||||
|
color="indigo"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Answer Rate"
|
||||||
|
value={`${Number(kpi?.kpi?.AnswerRate || 0).toFixed(1)}%`}
|
||||||
|
subLabel="voice"
|
||||||
|
trend="up"
|
||||||
|
icon={Award}
|
||||||
|
color="emerald"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Процент FCR"
|
||||||
|
value={`${Number(kpi?.kpi?.FCR || 0).toFixed(1)}%`}
|
||||||
|
subLabel="exact"
|
||||||
|
trend="up"
|
||||||
|
icon={Users}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Глобальное AHT"
|
||||||
|
value={`${Math.round(Number(kpi?.kpi?.AHT || 0))}с`}
|
||||||
|
subLabel="seconds"
|
||||||
|
trend="up"
|
||||||
|
icon={Clock}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Analytical Section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
{/* Interaction Trends Chart */}
|
||||||
|
<div className="lg:col-span-8 bg-white rounded-3xl border border-slate-200 p-6 shadow-sm flex flex-col min-h-[400px]">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Анализ объема и эффективности</h3>
|
||||||
|
<p className="text-[10px] text-slate-500 italic">Обращения против времени ответа за выбранный период</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-indigo-600"></span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Объем</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500"></span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Оценка CSAT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
{kpiData.length ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={kpiData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorInter" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#4f46e5" stopOpacity={0.1}/>
|
||||||
|
<stop offset="95%" stopColor="#4f46e5" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize: 10, fill: '#94a3b8'}} dy={10} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{fontSize: 10, fill: '#94a3b8'}} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{borderRadius: '16px', border: 'none', boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1)', background: '#fff'}}
|
||||||
|
labelStyle={{fontWeight: 'bold', fontSize: '12px'}}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="interactions" stroke="#4f46e5" strokeWidth={3} fillOpacity={1} fill="url(#colorInter)" />
|
||||||
|
<Area type="monotone" dataKey="csat" stroke="#10b981" strokeWidth={2} strokeDasharray="5 5" fill="transparent" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-full min-h-[260px] flex items-center justify-center text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
Нет обращений за выбранный период
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel Distribution */}
|
||||||
|
<div className="lg:col-span-4 bg-white rounded-3xl border border-slate-200 p-6 shadow-sm flex flex-col">
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-6">Популярность каналов</h3>
|
||||||
|
<div className="flex-1 relative min-h-[240px]">
|
||||||
|
{channelData.length ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={channelData}
|
||||||
|
innerRadius={70}
|
||||||
|
outerRadius={95}
|
||||||
|
paddingAngle={8}
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{channelData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
Нет каналов
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-3xl font-black text-slate-900 tracking-tighter italic">{Number(kpi?.kpi?.DigitalShare || 0).toFixed(0)}%</span>
|
||||||
|
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest">Цифровой охват</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-6 border-t border-slate-50">
|
||||||
|
{channelData.map(c => (
|
||||||
|
<div key={c.name} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }}></span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">{c.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-slate-900 ml-3.5 leading-none">{channelTotal ? Math.round((c.value / channelTotal) * 100) : 0}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
{/* Agent Rankings */}
|
||||||
|
<div className="lg:col-span-4 bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
|
||||||
|
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest">Рейтинг операторов</h3>
|
||||||
|
<LayoutGrid className="w-4 h-4 text-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-[300px]">
|
||||||
|
{agentData.map((agent) => (
|
||||||
|
<div key={agent.name} className="group p-4 border-b border-slate-50 flex items-center justify-between hover:bg-slate-50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center border border-slate-200 overflow-hidden">
|
||||||
|
<img src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${agent.name}`} alt={agent.name} className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-white",
|
||||||
|
agent.status === 'READY' ? "bg-emerald-500" : agent.status === 'BREAK' ? "bg-amber-500" : agent.status === 'BUSY' ? "bg-rose-500" : "bg-slate-400"
|
||||||
|
)}></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-slate-900 leading-tight italic">{agent.name}</div>
|
||||||
|
<p className="text-[10px] text-slate-500 font-medium">AHT: {agent.aht} • {agent.interactions} обращений</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-right">
|
||||||
|
<button className="p-2 text-slate-300 hover:text-indigo-600 transition-colors">
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!agentData.length && (
|
||||||
|
<div className="h-full min-h-[240px] flex items-center justify-center px-6 text-center text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
Нет операторской активности
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-slate-50 text-center">
|
||||||
|
<button className="text-[10px] font-bold text-slate-400 uppercase tracking-widest hover:text-indigo-600 transition-colors">
|
||||||
|
Всего операторов: {agentOverview?.totals?.agents_total || 0}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue Performance */}
|
||||||
|
<div className="lg:col-span-4 bg-white rounded-3xl border border-slate-200 p-6 shadow-sm flex flex-col">
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-6">Состояние очередей</h3>
|
||||||
|
<div className="space-y-6 flex-1">
|
||||||
|
{queuePerformance.map((queue) => (
|
||||||
|
<PerformanceRow
|
||||||
|
key={queue.name}
|
||||||
|
name={`${queue.name}${queue.inQueue ? ` • ${queue.inQueue} в очереди` : ''}`}
|
||||||
|
value={queue.value}
|
||||||
|
color={queue.value >= 90 ? "bg-indigo-600" : queue.value >= 75 ? "bg-emerald-500" : queue.value >= 50 ? "bg-amber-400" : "bg-rose-400"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!queuePerformance.length && (
|
||||||
|
<div className="h-full min-h-[220px] flex items-center justify-center text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
Нет данных по очередям
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 pt-4 border-t border-slate-50">
|
||||||
|
<div className="flex justify-between items-center text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
<span>Агрегированный SLA</span>
|
||||||
|
<span className="text-emerald-500 italic">{Number(kpi?.kpi?.SL || 0).toFixed(1)}% Достигнуто</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Insight / Summary */}
|
||||||
|
<div className="lg:col-span-4 bg-indigo-950 rounded-3xl p-6 shadow-xl text-white relative overflow-hidden group">
|
||||||
|
<div className="relative z-10 h-full flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<TrendingUp className="w-5 h-5 text-indigo-300" />
|
||||||
|
<h3 className="text-xs font-bold text-indigo-300 uppercase tracking-widest">Отчет ИИ</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="glass-morphism p-4 rounded-2xl bg-white/5 border border-white/10">
|
||||||
|
<p className="text-xs text-indigo-100 leading-relaxed italic">
|
||||||
|
{insights?.summary_text || 'Reporting-service пока не вернул операционный insight за выбранный период.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="text-center p-3 bg-white/5 rounded-2xl border border-white/10">
|
||||||
|
<div className="text-2xl font-bold text-indigo-200">{insights?.primary_metric.value || '0%'}</div>
|
||||||
|
<div className="text-[9px] font-bold uppercase tracking-widest text-indigo-400">{insights?.primary_metric.label || 'Digital Share'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/5 rounded-2xl border border-white/10">
|
||||||
|
<div className="text-2xl font-bold text-indigo-200">{insights?.secondary_metric.value || '0с'}</div>
|
||||||
|
<div className="text-[9px] font-bold uppercase tracking-widest text-indigo-400">{insights?.secondary_metric.label || 'ASA'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="w-full py-3 bg-indigo-600 rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-indigo-500 transition-all shadow-lg active:scale-95 mt-4">
|
||||||
|
Сгенерировать отчет
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-48 h-48 bg-indigo-500 rounded-full blur-[80px] opacity-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Historical Drilldown Section (Moved to separate row) */}
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm flex flex-col overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-slate-100 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest">Исторические данные</h3>
|
||||||
|
<p className="text-[10px] text-slate-500 italic">Детальный анализ сессий для оценки качества</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full sm:w-auto">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<input type="text" placeholder="Поиск обращений..." className="w-full sm:w-64 pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-[11px] focus:ring-4 focus:ring-indigo-500/5 focus:border-indigo-500 transition-all outline-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-x-auto min-h-[300px]">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[10px] font-bold text-slate-400 uppercase tracking-widest border-b border-slate-50">
|
||||||
|
<th className="px-6 py-4">ID Сессии</th>
|
||||||
|
<th className="px-6 py-4">Клиент</th>
|
||||||
|
<th className="px-6 py-4 hidden md:table-cell">Очередь</th>
|
||||||
|
<th className="px-6 py-4">Метрики</th>
|
||||||
|
<th className="px-6 py-4 text-right">Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50">
|
||||||
|
{historicalInteractions.map(interaction => (
|
||||||
|
<tr key={interaction.id} className="hover:bg-slate-50/50 transition-colors italic cursor-pointer group">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-bold text-slate-900 leading-tight">{interaction.id}</span>
|
||||||
|
<span className="text-[9px] text-slate-400 not-italic uppercase tracking-wider">{interaction.date}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-slate-100 border border-slate-200 flex items-center justify-center shrink-0">
|
||||||
|
<User className="w-3 h-3 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-700 truncate max-w-[100px]">{interaction.customer}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 hidden md:table-cell">
|
||||||
|
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-md text-[10px] font-bold uppercase tracking-widest border border-slate-200">{interaction.queue}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1.5 font-bold mb-0.5">
|
||||||
|
<span className="text-xs text-slate-900">{interaction.duration}</span>
|
||||||
|
<Clock className="w-3 h-3 text-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div className="flex text-emerald-500">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Award key={i} className={cn("w-2.5 h-2.5", i >= interaction.csat && "text-slate-200 opacity-50")} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button className="p-2 text-slate-300 hover:text-indigo-600 hover:bg-white rounded-lg transition-all opacity-0 group-hover:opacity-100 shadow-sm border border-transparent hover:border-slate-100">
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!historicalInteractions.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
Нет исторических обращений за выбранный период
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-slate-50 flex items-center justify-between font-bold text-slate-400 uppercase tracking-widest text-[9px]">
|
||||||
|
<span>Показано {historicalInteractions.length} из {drilldown?.total || 0} сессий</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="px-3 py-1 hover:text-indigo-600">Пред.</button>
|
||||||
|
<button className="px-3 py-1 hover:text-indigo-600">След.</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricCard: React.FC<{ label: string, value: string, subLabel: string, trend: 'up' | 'down', icon: any, color: string }> = ({ label, value, subLabel, trend, icon: Icon, color }) => (
|
||||||
|
<div className="bg-white p-5 rounded-3xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-100 transition-all cursor-default group relative overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between mb-4 relative z-10">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-2xl flex items-center justify-center transition-colors shadow-inner",
|
||||||
|
`bg-${color}-50 text-${color}-600 border border-${color}-100`
|
||||||
|
)}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-1 text-[10px] font-black uppercase tracking-tighter italic",
|
||||||
|
trend === 'up' ? "text-emerald-500" : "text-rose-500"
|
||||||
|
)}>
|
||||||
|
{trend === 'up' ? <ArrowUpRight className="w-3.5 h-3.5" /> : <ArrowDownRight className="w-3.5 h-3.5" />}
|
||||||
|
{subLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="text-3xl font-black text-slate-900 tracking-tighter mb-0.5 italic">{value}</div>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest leading-none">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute bottom-0 right-0 -mb-6 -mr-6 w-24 h-24 rounded-full blur-[40px] opacity-10 transition-opacity",
|
||||||
|
`bg-${color}-500`
|
||||||
|
)}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PerformanceRow: React.FC<{ name: string, value: number, color: string }> = ({ name, value, color }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex justify-between items-center text-[11px]">
|
||||||
|
<span className="font-bold text-slate-700 italic">{name}</span>
|
||||||
|
<span className="font-mono font-bold text-slate-900">{value}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full bg-slate-100 rounded-full overflow-hidden flex">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${value}%` }}
|
||||||
|
transition={{ duration: 1, ease: "easeOut" }}
|
||||||
|
className={cn("h-full rounded-full", color)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,806 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
Bot,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
MessageCircle,
|
||||||
|
MicOff,
|
||||||
|
MoreVertical,
|
||||||
|
Pause,
|
||||||
|
PhoneCall,
|
||||||
|
PhoneOff,
|
||||||
|
Send,
|
||||||
|
Share2,
|
||||||
|
ShieldAlert,
|
||||||
|
User as UserIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { ChannelType, Interaction } from '../../types';
|
||||||
|
import { useToast } from '../../components/ui/Toast';
|
||||||
|
import { EmptyState, LoadingState } from '../../components/ui/FeedbackStates';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import {
|
||||||
|
apiRequest,
|
||||||
|
BackendInteraction,
|
||||||
|
BackendLiveCall,
|
||||||
|
BackendThread,
|
||||||
|
BackendThreadMessage,
|
||||||
|
channelFromBackend,
|
||||||
|
} from '../../lib/api';
|
||||||
|
|
||||||
|
type SourceKind = 'interaction' | 'telegram' | 'whatsapp' | 'voice';
|
||||||
|
|
||||||
|
type UnifiedInteraction = Interaction & {
|
||||||
|
source: SourceKind;
|
||||||
|
backendId: string;
|
||||||
|
queueId?: string | null;
|
||||||
|
assignedTo?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
aiState?: string | null;
|
||||||
|
raw?: BackendInteraction | BackendThread | BackendLiveCall;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFromBackend = (status?: string): Interaction['status'] => {
|
||||||
|
if (status === 'closed' || status === 'abandoned' || status === 'ended') return 'CLOSED';
|
||||||
|
if (status === 'in_progress' || status === 'escalated' || status === 'claimed' || status === 'connected') return 'ACTIVE';
|
||||||
|
return 'PENDING';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (value?: string | null) => {
|
||||||
|
if (!value) return 'сейчас';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const channelLabel: Record<ChannelType, string> = {
|
||||||
|
[ChannelType.VOICE]: 'Голос',
|
||||||
|
[ChannelType.TELEGRAM]: 'Telegram',
|
||||||
|
[ChannelType.WHATSAPP]: 'WhatsApp',
|
||||||
|
[ChannelType.WEBCHAT]: 'Веб-чат',
|
||||||
|
[ChannelType.EMAIL]: 'Email',
|
||||||
|
};
|
||||||
|
|
||||||
|
const channelIconClass: Record<ChannelType, string> = {
|
||||||
|
[ChannelType.VOICE]: 'text-rose-500',
|
||||||
|
[ChannelType.TELEGRAM]: 'text-sky-500',
|
||||||
|
[ChannelType.WHATSAPP]: 'text-emerald-500',
|
||||||
|
[ChannelType.WEBCHAT]: 'text-indigo-500',
|
||||||
|
[ChannelType.EMAIL]: 'text-slate-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapInteraction = (item: BackendInteraction): UnifiedInteraction => ({
|
||||||
|
id: `interaction:${item.interaction_id}`,
|
||||||
|
source: 'interaction',
|
||||||
|
backendId: item.interaction_id,
|
||||||
|
channel: channelFromBackend(item.channel),
|
||||||
|
customerName: item.subject || item.interaction_id,
|
||||||
|
customerIdentifier: item.customer_id || item.queue_id || item.interaction_id,
|
||||||
|
lastMessage: item.subject,
|
||||||
|
timestamp: formatTime(item.updated_at || item.created_at),
|
||||||
|
status: statusFromBackend(item.status),
|
||||||
|
queueId: item.queue_id,
|
||||||
|
assignedTo: item.assigned_to,
|
||||||
|
createdAt: item.created_at,
|
||||||
|
updatedAt: item.updated_at,
|
||||||
|
raw: item,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapThread = (item: BackendThread, source: 'telegram' | 'whatsapp'): UnifiedInteraction => ({
|
||||||
|
id: `${source}:${item.thread_id}`,
|
||||||
|
source,
|
||||||
|
backendId: item.thread_id,
|
||||||
|
channel: source === 'telegram' ? ChannelType.TELEGRAM : ChannelType.WHATSAPP,
|
||||||
|
customerName: item.display_name || item.username || item.phone_number || item.chat_id,
|
||||||
|
customerIdentifier: item.username ? `@${item.username}` : item.phone_number || item.chat_id,
|
||||||
|
lastMessage: item.last_message_preview || 'Нет сообщений',
|
||||||
|
timestamp: formatTime(item.last_message_at),
|
||||||
|
status: statusFromBackend(item.status),
|
||||||
|
queueId: item.queue_id,
|
||||||
|
assignedTo: item.claimed_by_user,
|
||||||
|
createdAt: item.created_at,
|
||||||
|
updatedAt: item.updated_at,
|
||||||
|
aiState: item.ai_state,
|
||||||
|
raw: item,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapCall = (item: BackendLiveCall): UnifiedInteraction => ({
|
||||||
|
id: `voice:${item.call_id}`,
|
||||||
|
source: 'voice',
|
||||||
|
backendId: item.call_id,
|
||||||
|
channel: ChannelType.VOICE,
|
||||||
|
customerName: item.caller_name || item.caller_number || item.call_id,
|
||||||
|
customerIdentifier: item.caller_number || item.queue_code || item.queue_id,
|
||||||
|
lastMessage: item.telephony_status || item.status || 'live',
|
||||||
|
timestamp: formatTime(item.updated_at || item.started_at),
|
||||||
|
status: statusFromBackend(item.ended_at ? 'ended' : item.telephony_status || item.status),
|
||||||
|
queueId: item.queue_id,
|
||||||
|
assignedTo: item.claimed_by_user,
|
||||||
|
createdAt: item.started_at,
|
||||||
|
updatedAt: item.updated_at,
|
||||||
|
aiState: item.ai_state,
|
||||||
|
raw: item,
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadService = (source: SourceKind) => (source === 'whatsapp' ? 'whatsapp' : 'telegram');
|
||||||
|
|
||||||
|
export const OperatorView: React.FC<{ channelFilter?: ChannelType; title?: string }> = ({
|
||||||
|
channelFilter,
|
||||||
|
title = 'Рабочее место оператора',
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = React.useState<'all' | 'mine'>('all');
|
||||||
|
const [items, setItems] = React.useState<UnifiedInteraction[]>([]);
|
||||||
|
const [selectedInteraction, setSelectedInteraction] = React.useState<UnifiedInteraction | null>(null);
|
||||||
|
const [messages, setMessages] = React.useState<BackendThreadMessage[]>([]);
|
||||||
|
const [replyText, setReplyText] = React.useState('');
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const [isWorking, setIsWorking] = React.useState(false);
|
||||||
|
const [isSending, setIsSending] = React.useState(false);
|
||||||
|
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const loadWorkspace = React.useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
apiRequest<BackendInteraction[]>('interaction', 'interactions?limit=50'),
|
||||||
|
apiRequest<BackendThread[]>('telegram', 'integrations/telegram/threads?limit=50'),
|
||||||
|
apiRequest<BackendThread[]>('whatsapp', 'integrations/whatsapp/threads?limit=50'),
|
||||||
|
apiRequest<BackendLiveCall[]>('asterisk-bridge', 'asterisk/live-calls?limit=50'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nextItems = [
|
||||||
|
...(results[0].status === 'fulfilled' ? results[0].value.map(mapInteraction) : []),
|
||||||
|
...(results[1].status === 'fulfilled' ? results[1].value.map((item) => mapThread(item, 'telegram')) : []),
|
||||||
|
...(results[2].status === 'fulfilled' ? results[2].value.map((item) => mapThread(item, 'whatsapp')) : []),
|
||||||
|
...(results[3].status === 'fulfilled' ? results[3].value.map(mapCall) : []),
|
||||||
|
].sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
|
||||||
|
|
||||||
|
const rejected = results.find((result) => result.status === 'rejected');
|
||||||
|
if (!nextItems.length && rejected) {
|
||||||
|
setLoadError(rejected.reason instanceof Error ? rejected.reason.message : 'Backend недоступен');
|
||||||
|
setItems([]);
|
||||||
|
} else {
|
||||||
|
setItems(nextItems);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadWorkspace();
|
||||||
|
const timer = window.setInterval(loadWorkspace, 10000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [loadWorkspace]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedInteraction && items.length) {
|
||||||
|
setSelectedInteraction(items[0]);
|
||||||
|
}
|
||||||
|
}, [items, selectedInteraction]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedInteraction || !['telegram', 'whatsapp'].includes(selectedInteraction.source)) {
|
||||||
|
setMessages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const service = threadService(selectedInteraction.source);
|
||||||
|
apiRequest<BackendThreadMessage[]>(
|
||||||
|
service,
|
||||||
|
`integrations/${service}/threads/${encodeURIComponent(selectedInteraction.backendId)}/messages?limit=100`,
|
||||||
|
)
|
||||||
|
.then(setMessages)
|
||||||
|
.catch(() => setMessages([]));
|
||||||
|
}, [selectedInteraction]);
|
||||||
|
|
||||||
|
const visibleItems = React.useMemo(() => {
|
||||||
|
const channelItems = channelFilter ? items.filter((item) => item.channel === channelFilter) : items;
|
||||||
|
if (activeTab === 'mine') {
|
||||||
|
return channelItems.filter((item) => item.assignedTo === user?.username || item.status === 'ACTIVE');
|
||||||
|
}
|
||||||
|
return channelItems;
|
||||||
|
}, [activeTab, channelFilter, items, user?.username]);
|
||||||
|
|
||||||
|
const activeCount = items.filter((item) => item.status === 'ACTIVE').length;
|
||||||
|
const queueCount = items.filter((item) => item.status === 'PENDING').length;
|
||||||
|
const closedCount = items.filter((item) => item.status === 'CLOSED').length;
|
||||||
|
|
||||||
|
const handleTakeInWork = async (interaction: UnifiedInteraction) => {
|
||||||
|
setIsWorking(true);
|
||||||
|
try {
|
||||||
|
if (interaction.source === 'voice') {
|
||||||
|
await apiRequest<BackendLiveCall>('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(interaction.backendId)}/claim`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ operator_extension: '1001' }),
|
||||||
|
});
|
||||||
|
} else if (interaction.source === 'telegram' || interaction.source === 'whatsapp') {
|
||||||
|
const service = threadService(interaction.source);
|
||||||
|
await apiRequest<BackendThread>(service, `integrations/${service}/threads/${encodeURIComponent(interaction.backendId)}/claim`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiRequest<BackendInteraction>(
|
||||||
|
'interaction',
|
||||||
|
`interactions/${encodeURIComponent(interaction.backendId)}/status`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: 'in_progress' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
showToast('success', 'Обращение принято', `${interaction.customerName} теперь в рабочей зоне.`);
|
||||||
|
await loadWorkspace();
|
||||||
|
setSelectedInteraction((current) => (current ? { ...current, status: 'ACTIVE', assignedTo: user?.username } : current));
|
||||||
|
} catch (err) {
|
||||||
|
showToast('error', 'Backend отклонил действие', err instanceof Error ? err.message : 'Не удалось принять обращение');
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinish = async () => {
|
||||||
|
if (!selectedInteraction) return;
|
||||||
|
setIsWorking(true);
|
||||||
|
try {
|
||||||
|
if (selectedInteraction.source === 'voice') {
|
||||||
|
await apiRequest<BackendLiveCall>(
|
||||||
|
'asterisk-bridge',
|
||||||
|
`asterisk/live-calls/${encodeURIComponent(selectedInteraction.backendId)}/hangup`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
} else if (selectedInteraction.source === 'telegram' || selectedInteraction.source === 'whatsapp') {
|
||||||
|
const service = threadService(selectedInteraction.source);
|
||||||
|
await apiRequest<BackendThread>(service, `integrations/${service}/threads/${encodeURIComponent(selectedInteraction.backendId)}/close`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiRequest<BackendInteraction>(
|
||||||
|
'interaction',
|
||||||
|
`interactions/${encodeURIComponent(selectedInteraction.backendId)}/status`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: 'closed', resolved_first_contact: true }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
showToast('success', 'Обращение завершено', 'Статус сохранен в call-center backend.');
|
||||||
|
await loadWorkspace();
|
||||||
|
setSelectedInteraction(null);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('error', 'Не удалось завершить', err instanceof Error ? err.message : 'Backend error');
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendReply = async () => {
|
||||||
|
if (!selectedInteraction || !replyText.trim()) return;
|
||||||
|
if (!['telegram', 'whatsapp'].includes(selectedInteraction.source)) {
|
||||||
|
showToast('warning', 'Ответ доступен для мессенджеров', 'Для звонков и generic interactions используйте статусы.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const service = threadService(selectedInteraction.source);
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
const message = await apiRequest<BackendThreadMessage>(
|
||||||
|
service,
|
||||||
|
`integrations/${service}/threads/${encodeURIComponent(selectedInteraction.backendId)}/messages`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ text: replyText.trim() }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setMessages((current) => [...current, message]);
|
||||||
|
setReplyText('');
|
||||||
|
showToast('success', 'Ответ отправлен', `${channelLabel[selectedInteraction.channel]} принял сообщение.`);
|
||||||
|
await loadWorkspace();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('error', 'Сообщение не отправлено', err instanceof Error ? err.message : 'Backend error');
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-4 md:gap-6 pb-20 md:pb-0">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight">{title}</h2>
|
||||||
|
<p className="text-slate-500 text-sm hidden lg:block">Данные из call-center backend через API Gateway</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-px bg-slate-200 hidden md:block"></div>
|
||||||
|
<div className="flex gap-4 md:gap-6">
|
||||||
|
<Stat label="Активно" value={activeCount} tone="text-indigo-600" />
|
||||||
|
<Stat label="Очередь" value={queueCount} tone="text-rose-500" />
|
||||||
|
<Stat label="Закрыто" value={closedCount} tone="text-slate-700" className="hidden sm:flex" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
onClick={loadWorkspace}
|
||||||
|
className="px-3 py-1.5 md:px-4 md:py-2 bg-emerald-50 text-emerald-700 rounded-lg border border-emerald-100 flex items-center gap-2 text-[10px] md:text-sm font-medium w-full sm:w-auto justify-center"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />}
|
||||||
|
<span className="truncate">{loadError ? 'Backend недоступен' : 'Backend подключен'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && !items.length ? (
|
||||||
|
<LoadingState message="Загрузка очередей из call-center backend..." />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 md:gap-6 flex-1 min-h-0 relative">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'md:col-span-4 lg:col-span-3 flex flex-col bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm',
|
||||||
|
selectedInteraction ? 'hidden md:flex' : 'flex h-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-slate-100 flex gap-1 bg-slate-50/50">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 py-1.5 text-xs font-semibold rounded-md transition-all',
|
||||||
|
activeTab === 'all' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-500 hover:text-slate-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Входящие
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('mine')}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 py-1.5 text-xs font-semibold rounded-md transition-all',
|
||||||
|
activeTab === 'mine' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-500 hover:text-slate-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
В работе
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{visibleItems.length ? (
|
||||||
|
visibleItems.map((item) => (
|
||||||
|
<InteractionRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
selected={selectedInteraction?.id === item.id}
|
||||||
|
onSelect={() => setSelectedInteraction(item)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Нет обращений" description="Backend вернул пустую очередь для выбранного фильтра." className="h-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm relative h-full transition-all duration-300',
|
||||||
|
selectedInteraction ? 'col-span-1 md:col-span-8 lg:col-span-6' : 'hidden md:flex md:col-span-8 lg:col-span-9',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedInteraction ? (
|
||||||
|
<>
|
||||||
|
<div className="p-4 border-b border-slate-100 flex items-center justify-between bg-white z-10 sticky top-0">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<button onClick={() => setSelectedInteraction(null)} className="md:hidden p-2 -ml-2 text-slate-400 hover:text-slate-600">
|
||||||
|
<ArrowRight className="w-5 h-5 rotate-180" />
|
||||||
|
</button>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center border border-indigo-200 group relative hidden sm:flex">
|
||||||
|
{selectedInteraction.channel === ChannelType.VOICE ? (
|
||||||
|
<PhoneCall className={cn('w-5 h-5', channelIconClass[selectedInteraction.channel])} />
|
||||||
|
) : (
|
||||||
|
<MessageCircle className={cn('w-5 h-5', channelIconClass[selectedInteraction.channel])} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-bold text-slate-900 text-sm leading-tight flex items-center gap-2 truncate">
|
||||||
|
{selectedInteraction.customerName}
|
||||||
|
{selectedInteraction.status === 'ACTIVE' && <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0"></span>}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-slate-500 font-medium tracking-wide truncate">
|
||||||
|
{channelLabel[selectedInteraction.channel]} · {selectedInteraction.customerIdentifier}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedInteraction.status === 'PENDING' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleTakeInWork(selectedInteraction)}
|
||||||
|
disabled={isWorking}
|
||||||
|
className="px-4 md:px-6 py-2 bg-indigo-600 text-white rounded-xl text-xs font-bold hover:bg-indigo-700 transition-all shadow-md active:scale-95 whitespace-nowrap disabled:bg-slate-400"
|
||||||
|
>
|
||||||
|
{isWorking ? '...' : 'В работу'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
<button className="flex lg:hidden items-center justify-center p-2 border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors">
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<div className="hidden lg:flex items-center gap-2">
|
||||||
|
<button className="flex items-center gap-2 px-3 py-1.5 border border-slate-200 rounded-lg text-[10px] font-bold uppercase tracking-widest text-slate-600 hover:bg-slate-50 transition-colors">
|
||||||
|
<Share2 className="w-3 h-3" />
|
||||||
|
Передать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleFinish}
|
||||||
|
disabled={isWorking}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 border border-rose-200 text-rose-600 rounded-lg text-[10px] font-bold uppercase tracking-widest hover:bg-rose-50 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isWorking ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle2 className="w-3 h-3" />}
|
||||||
|
Завершить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto bg-slate-50/50 flex flex-col relative pb-32 md:pb-0">
|
||||||
|
{selectedInteraction.channel === ChannelType.VOICE ? (
|
||||||
|
<VoiceWorkspace interaction={selectedInteraction} onClaim={() => handleTakeInWork(selectedInteraction)} onHangup={handleFinish} isWorking={isWorking} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 p-6 space-y-6">
|
||||||
|
<div className="flex flex-col items-center py-4">
|
||||||
|
<span className="px-3 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold rounded-full uppercase tracking-widest">
|
||||||
|
Поток из {selectedInteraction.source === 'interaction' ? 'interaction-service' : `${selectedInteraction.source}-adapter`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{messages.length ? (
|
||||||
|
messages.map((message) => <MessageBubble key={message.message_id} message={message} />)
|
||||||
|
) : (
|
||||||
|
<MessageBubble
|
||||||
|
message={{
|
||||||
|
message_id: 'preview',
|
||||||
|
thread_id: selectedInteraction.backendId,
|
||||||
|
interaction_id: selectedInteraction.backendId,
|
||||||
|
chat_id: selectedInteraction.customerIdentifier,
|
||||||
|
direction: 'inbound',
|
||||||
|
text: selectedInteraction.lastMessage || selectedInteraction.customerName,
|
||||||
|
created_at: selectedInteraction.createdAt || new Date().toISOString(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-100 bg-white">
|
||||||
|
<div className="bg-slate-50 rounded-2xl border border-slate-200 p-3 mb-2 flex items-center gap-3">
|
||||||
|
<Bot className="w-4 h-4 text-indigo-500" />
|
||||||
|
<p className="text-[11px] text-indigo-800 font-medium">
|
||||||
|
AI state: <span className="font-bold">{selectedInteraction.aiState || 'нет активной сессии'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(event) => setReplyText(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||||
|
handleSendReply();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Напишите ответ оператором..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full pl-4 pr-12 py-3 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-4 focus:ring-indigo-500/5 focus:border-indigo-500 resize-none transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSendReply}
|
||||||
|
disabled={isSending || !replyText.trim()}
|
||||||
|
className="absolute right-2 bottom-3 p-2.5 bg-indigo-600 text-white rounded-lg shadow-md hover:bg-indigo-700 transition-all active:scale-95 disabled:bg-slate-300"
|
||||||
|
>
|
||||||
|
{isSending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="text-[10px] font-bold text-slate-400 hover:text-slate-600 uppercase tracking-widest flex items-center gap-1 transition-colors">
|
||||||
|
<Share2 className="w-3 h-3" />
|
||||||
|
Файл
|
||||||
|
</button>
|
||||||
|
<button className="text-[10px] font-bold text-slate-400 hover:text-slate-600 uppercase tracking-widest flex items-center gap-1 transition-colors">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Отложить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-medium text-slate-400 italic">Ctrl+Enter для отправки</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Готов к работе" description="Выберите клиента из backend-очереди, чтобы начать." className="flex-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'lg:col-span-3 flex-col gap-4 md:gap-6 lg:flex',
|
||||||
|
selectedInteraction ? 'flex col-span-1 md:col-span-12 lg:col-span-3' : 'hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SmartSummary selectedInteraction={selectedInteraction} />
|
||||||
|
<CustomerPanel selectedInteraction={selectedInteraction} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stat: React.FC<{ label: string; value: number; tone: string; className?: string }> = ({ label, value, tone, className }) => (
|
||||||
|
<div className={cn('flex flex-col', className)}>
|
||||||
|
<span className="text-[9px] md:text-[10px] font-bold text-slate-400 uppercase tracking-widest leading-none">{label}</span>
|
||||||
|
<span className={cn('text-xs md:text-sm font-semibold', tone)}>{String(value).padStart(2, '0')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InteractionRow: React.FC<{ item: UnifiedInteraction; selected: boolean; onSelect: () => void }> = ({ item, selected, onSelect }) => (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left p-4 border-b border-slate-50 flex gap-3 transition-colors cursor-pointer group',
|
||||||
|
selected ? 'bg-indigo-50/30 border-l-2 border-l-indigo-600' : 'hover:bg-slate-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200">
|
||||||
|
{item.channel === ChannelType.VOICE ? (
|
||||||
|
<PhoneCall className={cn('w-5 h-5', channelIconClass[item.channel], item.status === 'PENDING' && 'animate-bounce')} />
|
||||||
|
) : (
|
||||||
|
<MessageCircle className={cn('w-5 h-5', channelIconClass[item.channel])} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex justify-between items-start mb-0.5 gap-2">
|
||||||
|
<span className="font-semibold text-sm text-slate-900 truncate">{item.customerName}</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-medium shrink-0">{item.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-slate-500 truncate italic">{item.lastMessage}</p>
|
||||||
|
{item.status === 'PENDING' && (
|
||||||
|
<span className="text-[8px] font-bold bg-rose-100 text-rose-600 px-1.5 py-0.5 rounded uppercase tracking-tighter shrink-0">Новое</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MessageBubble: React.FC<{ message: BackendThreadMessage }> = ({ message }) => {
|
||||||
|
const outbound = message.direction === 'outbound' || message.author_type === 'human';
|
||||||
|
if (message.direction === 'system') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center py-2">
|
||||||
|
<span className="px-3 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold rounded-full uppercase tracking-widest">{message.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-3 max-w-[85%]', outbound && 'ml-auto flex-row-reverse')}>
|
||||||
|
{!outbound && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center self-end mb-1 border border-indigo-200 shrink-0">
|
||||||
|
<UserIcon className="w-4 h-4 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-4 rounded-2xl shadow-sm text-sm leading-relaxed',
|
||||||
|
outbound ? 'bg-slate-900 rounded-br-none text-white shadow-lg' : 'bg-white rounded-bl-none border border-slate-100 text-slate-700 italic',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
<div className={cn('mt-2 text-[9px]', outbound ? 'text-white/50' : 'text-slate-400')}>{formatTime(message.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VoiceWorkspace: React.FC<{
|
||||||
|
interaction: UnifiedInteraction;
|
||||||
|
onClaim: () => void;
|
||||||
|
onHangup: () => void;
|
||||||
|
isWorking: boolean;
|
||||||
|
}> = ({ interaction, onClaim, onHangup, isWorking }) => {
|
||||||
|
if (interaction.status === 'PENDING') {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center animate-in fade-in duration-500">
|
||||||
|
<div className="w-24 h-24 bg-rose-50 rounded-full flex items-center justify-center mb-6 relative">
|
||||||
|
<div className="absolute inset-0 bg-rose-500/20 rounded-full animate-ping"></div>
|
||||||
|
<PhoneCall className="w-10 h-10 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xl font-bold text-slate-900 mb-2 italic">Входящий голосовой звонок</h4>
|
||||||
|
<p className="text-sm text-slate-500 mb-8 max-w-xs">
|
||||||
|
{interaction.customerName} ожидает в очереди {interaction.queueId || 'default'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onClaim}
|
||||||
|
disabled={isWorking}
|
||||||
|
className="w-16 h-16 bg-emerald-500 text-white rounded-full flex items-center justify-center shadow-lg shadow-emerald-500/30 hover:bg-emerald-600 transition-all active:scale-95 disabled:bg-slate-300"
|
||||||
|
>
|
||||||
|
{isWorking ? <Loader2 className="w-6 h-6 animate-spin" /> : <PhoneCall className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onHangup}
|
||||||
|
disabled={isWorking}
|
||||||
|
className="w-16 h-16 bg-rose-500 text-white rounded-full flex items-center justify-center shadow-lg shadow-rose-500/30 hover:bg-rose-600 transition-all active:scale-95 disabled:bg-slate-300"
|
||||||
|
>
|
||||||
|
<PhoneOff className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-12">
|
||||||
|
<div className="w-32 h-32 rounded-full border-4 border-indigo-100 flex items-center justify-center relative mb-6">
|
||||||
|
<div className="absolute inset-0 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<UserIcon className="w-16 h-16 text-indigo-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 mb-1 leading-tight">{interaction.customerName}</h3>
|
||||||
|
<div className="flex items-center gap-2 text-indigo-600 font-mono text-xl mb-8">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse"></div>
|
||||||
|
{interaction.lastMessage || 'connected'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-6">
|
||||||
|
<CallControlButton icon={MicOff} label="Выкл звук" />
|
||||||
|
<CallControlButton icon={Pause} label="Удержать" />
|
||||||
|
<CallControlButton icon={Share2} label="Передать" />
|
||||||
|
<CallControlButton icon={PhoneOff} label="Сброс" color="rose" onClick={onHangup} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-48 bg-white border-t border-slate-100 p-4 overflow-y-auto">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">AI состояние звонка</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<p className="text-slate-500 italic">
|
||||||
|
<span className="font-bold text-slate-900 not-italic">Call ID:</span> {interaction.backendId}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-500 italic">
|
||||||
|
<span className="font-bold text-indigo-600 not-italic">AI:</span> {interaction.aiState || 'нет активной AI-сессии'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SmartSummary: React.FC<{ selectedInteraction: UnifiedInteraction | null }> = ({ selectedInteraction }) => (
|
||||||
|
<div className="bg-indigo-950 rounded-2xl p-4 md:p-5 shadow-xl text-white relative overflow-hidden group">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="w-4 h-4 text-indigo-300" />
|
||||||
|
<span className="text-xs font-bold text-indigo-300 uppercase tracking-widest">Интеллект ИИ</span>
|
||||||
|
</div>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
|
||||||
|
<h4 className="text-[10px] font-bold text-indigo-300 uppercase tracking-widest mb-2">Источник</h4>
|
||||||
|
<p className="text-xs text-white italic font-medium">{selectedInteraction?.source || 'backend'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold text-indigo-300 uppercase tracking-widest mb-2 leading-tight">Краткое содержание</h4>
|
||||||
|
<p className="text-[11px] text-indigo-100/80 leading-relaxed italic">
|
||||||
|
{selectedInteraction?.lastMessage || 'Выберите обращение, чтобы увидеть backend-контекст.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="flex-1 py-2 bg-indigo-600 rounded-lg text-[10px] font-bold uppercase tracking-widest hover:bg-indigo-500 transition-colors">Приоритет +</button>
|
||||||
|
<button className="flex-1 py-2 bg-white/10 rounded-lg text-[10px] font-bold uppercase tracking-widest hover:bg-white/20 transition-colors">История</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CustomerPanel: React.FC<{ selectedInteraction: UnifiedInteraction | null }> = ({ selectedInteraction }) => (
|
||||||
|
<>
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest">Профиль клиента</h3>
|
||||||
|
<button className="text-[10px] font-bold text-indigo-600 hover:underline uppercase tracking-widest">Изм.</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-slate-100 flex items-center justify-center border border-slate-200">
|
||||||
|
<UserIcon className="w-6 h-6 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-bold text-slate-900 italic truncate">{selectedInteraction?.customerName || 'Не выбран'}</div>
|
||||||
|
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-widest truncate">ID: {selectedInteraction?.backendId || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-2 bg-slate-50 rounded-lg border border-slate-100">
|
||||||
|
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-0.5">Статус</div>
|
||||||
|
<div className="text-xs font-bold text-rose-500 italic flex items-center gap-1">
|
||||||
|
{selectedInteraction?.status || '-'}
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-slate-50 rounded-lg border border-slate-100">
|
||||||
|
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-0.5">Очередь</div>
|
||||||
|
<div className="text-xs font-bold text-slate-900 truncate">{selectedInteraction?.queueId || 'default'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[11px] flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">Контакт</span>
|
||||||
|
<span className="text-slate-900 font-medium truncate">{selectedInteraction?.customerIdentifier || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">Назначен</span>
|
||||||
|
<span className="text-slate-900 font-medium truncate">{selectedInteraction?.assignedTo || 'не назначен'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-5 shadow-sm flex-1 flex flex-col min-h-0">
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">История активности</h3>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-5 pr-1">
|
||||||
|
<TimelineItem date={selectedInteraction?.timestamp || 'сейчас'} action="Открыто в DigOpsCC" type={selectedInteraction?.source || 'backend'} status="Текущее" icon={PhoneCall} color="indigo" />
|
||||||
|
<TimelineItem date="Gateway" action="/proxy/{service}/{path}" type="Контракт старого UI" status="Завершено" icon={ShieldAlert} color="slate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TimelineItem: React.FC<{ date: string; action: string; type: string; status: string; icon: any; color: 'indigo' | 'slate' }> = ({
|
||||||
|
date,
|
||||||
|
action,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
icon: Icon,
|
||||||
|
color,
|
||||||
|
}) => (
|
||||||
|
<div className="relative pl-6 pb-2">
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-px bg-slate-100 ml-2"></div>
|
||||||
|
<div className={cn('absolute left-0 top-1 w-4 h-4 rounded-full border-2 border-white shadow-sm flex items-center justify-center z-10 ml-0', color === 'indigo' ? 'bg-indigo-500' : 'bg-slate-500')}>
|
||||||
|
<Icon className="w-2 h-2 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{date}</span>
|
||||||
|
<span className={cn('text-[8px] font-bold px-1 py-0.5 rounded uppercase tracking-tighter', status === 'Текущее' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-100 text-slate-600')}>{status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-bold text-slate-900 leading-tight mb-1 italic">{action}</div>
|
||||||
|
<div className="text-[10px] text-slate-500 italic font-medium">{type}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CallControlButton: React.FC<{ icon: any; label: string; color?: string; onClick?: () => void }> = ({ icon: Icon, label, color = 'slate', onClick }) => (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'w-12 h-12 rounded-2xl flex items-center justify-center transition-all active:scale-95 border border-slate-200/50 shadow-sm',
|
||||||
|
color === 'rose' ? 'bg-rose-50 text-rose-600 hover:bg-rose-100' : 'bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-900',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowDownRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
PhoneIncoming,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { apiRequest, BackendAgentState, BackendKpi, BackendQueue, BackendRealtime, BackendTimeseries } from '../../lib/api';
|
||||||
|
import { LoadingState } from '../../components/ui/FeedbackStates';
|
||||||
|
|
||||||
|
const emptyChartData = [
|
||||||
|
{ time: '09:00', volume: 0, abandoned: 0 },
|
||||||
|
{ time: '10:00', volume: 0, abandoned: 0 },
|
||||||
|
{ time: '11:00', volume: 0, abandoned: 0 },
|
||||||
|
{ time: '12:00', volume: 0, abandoned: 0 },
|
||||||
|
{ time: '13:00', volume: 0, abandoned: 0 },
|
||||||
|
{ time: '14:00', volume: 0, abandoned: 0 },
|
||||||
|
{ time: '15:00', volume: 0, abandoned: 0 },
|
||||||
|
{ time: '16:00', volume: 0, abandoned: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const secondsToClock = (seconds?: number) => {
|
||||||
|
const safe = Math.max(0, Math.round(Number(seconds || 0)));
|
||||||
|
const minutes = Math.floor(safe / 60);
|
||||||
|
return `${String(minutes).padStart(2, '0')}:${String(safe % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueTone = (inQueue: number) => {
|
||||||
|
if (inQueue >= 25) return { status: 'КРИТИЧНО', color: 'rose' };
|
||||||
|
if (inQueue >= 10) return { status: 'ЗАДЕРЖКА', color: 'amber' };
|
||||||
|
if (inQueue > 0) return { status: 'ХОРОШО', color: 'indigo' };
|
||||||
|
return { status: 'ОТЛИЧНО', color: 'emerald' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SupervisorView: React.FC = () => {
|
||||||
|
const [realtime, setRealtime] = React.useState<BackendRealtime | null>(null);
|
||||||
|
const [kpi, setKpi] = React.useState<BackendKpi | null>(null);
|
||||||
|
const [queues, setQueues] = React.useState<BackendQueue[]>([]);
|
||||||
|
const [agents, setAgents] = React.useState<BackendAgentState[]>([]);
|
||||||
|
const [volumeTimeseries, setVolumeTimeseries] = React.useState<BackendTimeseries | null>(null);
|
||||||
|
const [abandonedTimeseries, setAbandonedTimeseries] = React.useState<BackendTimeseries | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadSupervisorData = React.useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now);
|
||||||
|
from.setHours(now.getHours() - 8);
|
||||||
|
const windowQuery = new URLSearchParams({
|
||||||
|
from_ts: from.toISOString(),
|
||||||
|
to_ts: now.toISOString(),
|
||||||
|
interval: 'hour',
|
||||||
|
});
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
apiRequest<BackendRealtime>('supervisor', 'supervisor/realtime'),
|
||||||
|
apiRequest<BackendKpi>('reporting', 'reports/kpi'),
|
||||||
|
apiRequest<BackendQueue[]>('routing', 'queues'),
|
||||||
|
apiRequest<BackendAgentState[]>('supervisor', 'supervisor/agents'),
|
||||||
|
apiRequest<BackendTimeseries>('reporting', `reports/timeseries?${windowQuery.toString()}&metric=total`),
|
||||||
|
apiRequest<BackendTimeseries>('reporting', `reports/timeseries?${windowQuery.toString()}&metric=abandoned`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (results[0].status === 'fulfilled') setRealtime(results[0].value);
|
||||||
|
if (results[1].status === 'fulfilled') setKpi(results[1].value);
|
||||||
|
if (results[2].status === 'fulfilled') setQueues(results[2].value);
|
||||||
|
if (results[3].status === 'fulfilled') setAgents(results[3].value);
|
||||||
|
if (results[4].status === 'fulfilled') setVolumeTimeseries(results[4].value);
|
||||||
|
if (results[5].status === 'fulfilled') setAbandonedTimeseries(results[5].value);
|
||||||
|
|
||||||
|
const rejected = results.find((result) => result.status === 'rejected');
|
||||||
|
if (rejected) {
|
||||||
|
setError(rejected.reason instanceof Error ? rejected.reason.message : 'Часть backend данных недоступна');
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadSupervisorData();
|
||||||
|
const timer = window.setInterval(loadSupervisorData, 10000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [loadSupervisorData]);
|
||||||
|
|
||||||
|
const byState = realtime?.agents.by_state || {};
|
||||||
|
const queueMap = new Map<string, BackendQueue>(queues.map((queue) => [queue.queue_id, queue] as const));
|
||||||
|
const realtimeQueues = realtime?.queues || [];
|
||||||
|
const busiestQueue = realtimeQueues.reduce((max, queue) => (queue.in_queue > (max?.in_queue || 0) ? queue : max), realtimeQueues[0]);
|
||||||
|
const totalInQueue = realtimeQueues.reduce((sum, queue) => sum + Number(queue.in_queue || 0), 0);
|
||||||
|
const agentRows = agents.length ? agents : realtime?.agents.items || [];
|
||||||
|
const chartData = React.useMemo(() => {
|
||||||
|
const abandonedByTs = new Map(
|
||||||
|
(abandonedTimeseries?.points || []).map((point) => [point.ts || point.bucket || '', Number(point.value || 0)]),
|
||||||
|
);
|
||||||
|
const points = volumeTimeseries?.points || [];
|
||||||
|
if (!points.length) return emptyChartData;
|
||||||
|
return points.map((point) => ({
|
||||||
|
time: new Date(point.ts || point.bucket || Date.now()).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
volume: Number(point.value || 0),
|
||||||
|
abandoned: abandonedByTs.get(point.ts || point.bucket || '') || 0,
|
||||||
|
}));
|
||||||
|
}, [abandonedTimeseries, volumeTimeseries]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">Консоль супервайзера</h2>
|
||||||
|
<p className="text-slate-500 text-sm italic">Realtime данные из supervisor-service и KPI из reporting-service</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={loadSupervisorData}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-colors shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && !realtime ? (
|
||||||
|
<LoadingState message="Синхронизация realtime snapshot..." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||||
|
Часть данных пока недоступна: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<KPIItem title="Уровень сервиса" value={`${Number(kpi?.kpi?.SL || 0).toFixed(1)}%`} trend="SL" positive icon={Activity} />
|
||||||
|
<KPIItem title="Ср. время обработки" value={secondsToClock(kpi?.kpi?.AHT)} trend="AHT" positive icon={Clock} />
|
||||||
|
<KPIItem title="Ожидание в очереди" value={String(totalInQueue)} trend="Live" positive={totalInQueue < 10} icon={PhoneIncoming} />
|
||||||
|
<KPIItem title="Операторов в сети" value={String(byState.READY || 0)} trend={`${realtime?.agents.total || 0} всего`} positive icon={Users} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 bg-white rounded-2xl border border-slate-200 p-6 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-sm font-bold text-slate-900 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-indigo-500" />
|
||||||
|
Объем обращений
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-4 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-indigo-500"></span> Обработано
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-rose-500"></span> Пропущено
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorVol" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#4f46e5" stopOpacity={0.1} />
|
||||||
|
<stop offset="95%" stopColor="#4f46e5" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||||
|
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} dy={10} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} />
|
||||||
|
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }} />
|
||||||
|
<Area type="monotone" dataKey="volume" stroke="#4f46e5" fillOpacity={1} fill="url(#colorVol)" strokeWidth={2} />
|
||||||
|
<Area type="monotone" dataKey="abandoned" stroke="#f43f5e" fill="transparent" strokeWidth={2} strokeDasharray="5 5" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-5 shadow-sm">
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-6">Состояние очередей</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{realtimeQueues.length ? (
|
||||||
|
realtimeQueues.map((queue) => {
|
||||||
|
const queueInfo = queueMap.get(queue.queue_id);
|
||||||
|
const tone = queueTone(queue.in_queue);
|
||||||
|
return (
|
||||||
|
<QueueItem
|
||||||
|
key={queue.queue_id}
|
||||||
|
name={queueInfo?.name || queue.queue_id}
|
||||||
|
count={queue.in_queue}
|
||||||
|
status={tone.status}
|
||||||
|
color={tone.color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<QueueItem name="Очереди не заполнены" count={0} status="НЕТ ДАННЫХ" color="slate" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 p-4 bg-slate-50 rounded-xl border border-slate-100">
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-2">Макс. ожидание</div>
|
||||||
|
<div className="text-2xl font-mono italic text-slate-900">{secondsToClock(busiestQueue?.avg_wait_seconds)}</div>
|
||||||
|
<div className="text-[10px] text-slate-500 mt-1 uppercase italic tracking-widest">
|
||||||
|
В {queueMap.get(busiestQueue?.queue_id || '')?.name || busiestQueue?.queue_id || 'очереди'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-bold text-slate-900 uppercase tracking-widest">Управление активной командой</h3>
|
||||||
|
<input type="text" placeholder="Фильтр операторов..." className="text-xs border border-slate-200 rounded-lg px-3 py-1.5 focus:outline-none focus:border-indigo-500" />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50/50 text-[10px] uppercase font-bold text-slate-500 tracking-wider">
|
||||||
|
<th className="px-6 py-4">Имя оператора</th>
|
||||||
|
<th className="px-6 py-4">Статус</th>
|
||||||
|
<th className="px-6 py-4">Очередь</th>
|
||||||
|
<th className="px-6 py-4">Загрузка</th>
|
||||||
|
<th className="px-6 py-4">Обновлено</th>
|
||||||
|
<th className="px-6 py-4"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50 text-sm">
|
||||||
|
{agentRows.map((agent) => (
|
||||||
|
<AgentRow key={agent.agent_id} agent={agent} queueName={queueMap.get(agent.queue_id || '')?.name} />
|
||||||
|
))}
|
||||||
|
{!agentRows.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">
|
||||||
|
Supervisor-service пока не вернул агентские состояния.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const KPIItem: React.FC<{ title: string; value: string; trend: string; positive: boolean; icon: any }> = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
trend,
|
||||||
|
positive,
|
||||||
|
icon: Icon,
|
||||||
|
}) => (
|
||||||
|
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center">
|
||||||
|
<Icon className="w-5 h-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-full uppercase tracking-widest', positive ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600')}>
|
||||||
|
{positive ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
||||||
|
{trend}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">{title}</div>
|
||||||
|
<div className="text-2xl font-bold text-slate-900 tracking-tight">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const QueueItem: React.FC<{ name: string; count: number; status: string; color: string }> = ({ name, count, status, color }) => {
|
||||||
|
const tone =
|
||||||
|
color === 'rose'
|
||||||
|
? 'bg-rose-50 text-rose-600'
|
||||||
|
: color === 'amber'
|
||||||
|
? 'bg-amber-50 text-amber-600'
|
||||||
|
: color === 'emerald'
|
||||||
|
? 'bg-emerald-50 text-emerald-600'
|
||||||
|
: color === 'indigo'
|
||||||
|
? 'bg-indigo-50 text-indigo-600'
|
||||||
|
: 'bg-slate-100 text-slate-600';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-slate-900">{name}</div>
|
||||||
|
<div className={cn('text-[9px] font-bold uppercase tracking-widest px-1.5 py-0.5 rounded italic', tone)}>{status}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-slate-900 font-mono italic">{count}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgentRow: React.FC<{ agent: BackendAgentState; queueName?: string }> = ({ agent, queueName }) => {
|
||||||
|
const statusTone = {
|
||||||
|
READY: 'bg-emerald-500 shadow-sm shadow-emerald-500/50',
|
||||||
|
BUSY: 'bg-rose-500 shadow-sm shadow-rose-500/50',
|
||||||
|
BREAK: 'bg-amber-500 shadow-sm shadow-amber-500/50',
|
||||||
|
OFFLINE: 'bg-slate-400',
|
||||||
|
}[agent.state];
|
||||||
|
const statusLabel = {
|
||||||
|
READY: 'Готов',
|
||||||
|
BUSY: 'Занят',
|
||||||
|
BREAK: 'Перерыв',
|
||||||
|
OFFLINE: 'Оффлайн',
|
||||||
|
}[agent.state];
|
||||||
|
const load = agent.state === 'BUSY' ? 3 : agent.state === 'READY' ? 1 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="hover:bg-slate-50/50 transition-colors group">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
|
||||||
|
<Users className="w-4 h-4 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold text-slate-900">{agent.agent_id}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn('w-1.5 h-1.5 rounded-full', statusTone)}></span>
|
||||||
|
<span className="text-xs font-medium text-slate-600 uppercase tracking-widest">{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-slate-500 font-medium italic">{queueName || agent.queue_id || '-'}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<div key={index} className={cn('w-4 h-1.5 rounded-full', index < load ? 'bg-indigo-500' : 'bg-slate-200')}></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-xs text-slate-600">{agent.updated_at ? new Date(agent.updated_at).toLocaleTimeString('ru-RU') : '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg group-hover:opacity-100 opacity-0 transition-all">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum UserRole {
|
||||||
|
OPERATOR = 'OPERATOR',
|
||||||
|
SUPERVISOR = 'SUPERVISOR',
|
||||||
|
ADMIN = 'ADMIN',
|
||||||
|
ANALYST = 'ANALYST',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserStatus {
|
||||||
|
ONLINE = 'ONLINE',
|
||||||
|
AWAY = 'AWAY',
|
||||||
|
BUSY = 'BUSY',
|
||||||
|
OFFLINE = 'OFFLINE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username?: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
status: UserStatus;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChannelType {
|
||||||
|
VOICE = 'VOICE',
|
||||||
|
TELEGRAM = 'TELEGRAM',
|
||||||
|
WHATSAPP = 'WHATSAPP',
|
||||||
|
WEBCHAT = 'WEBCHAT',
|
||||||
|
EMAIL = 'EMAIL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interaction {
|
||||||
|
id: string;
|
||||||
|
channel: ChannelType;
|
||||||
|
customerName: string;
|
||||||
|
customerIdentifier: string;
|
||||||
|
lastMessage?: string;
|
||||||
|
timestamp: string;
|
||||||
|
status: 'PENDING' | 'ACTIVE' | 'CLOSED';
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(({mode}) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
const gatewayUrl = env.VITE_CC_GATEWAY_URL || 'http://localhost:8080';
|
||||||
|
return {
|
||||||
|
base: env.VITE_BASE_PATH || (mode === 'production' ? '/omnichannel/' : '/'),
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
proxy: {
|
||||||
|
'/health': {
|
||||||
|
target: gatewayUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/proxy': {
|
||||||
|
target: gatewayUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user