init: DigOpsCC UI - React frontend for call-center backend (migrated from /opt untracked source)

This commit is contained in:
konturai-ops
2026-08-10 12:44:09 +00:00
commit 82826f8857
31 changed files with 9381 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.git
.env*
*.log
coverage
.DS_Store
+9
View File
@@ -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"
+8
View File
@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
+18
View File
@@ -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
+48
View File
@@ -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`.
+41
View File
@@ -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
View File
@@ -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>
+6
View File
@@ -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": []
}
+4797
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
};
+117
View File
@@ -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>
</>
);
};
+123
View File
@@ -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>
);
};
+53
View File
@@ -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>
);
+95
View File
@@ -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>
);
};
+110
View File
@@ -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;
};
+45
View File
@@ -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
View File
@@ -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');
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+10
View File
@@ -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>,
);
+271
View File
@@ -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>
);
};
+591
View File
@@ -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>
);
};
+584
View File
@@ -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>
);
+616
View File
@@ -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>
);
+806
View File
@@ -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>
);
+356
View File
@@ -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>
);
};
+46
View File
@@ -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';
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
+26
View File
@@ -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
}
}
+36
View File
@@ -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,
},
},
},
};
});