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