344 lines
14 KiB
TypeScript
Executable File
344 lines
14 KiB
TypeScript
Executable File
|
||
import React, { useState, useEffect } from 'react';
|
||
import { Star, MessageCircle, ThumbsUp, ThumbsDown, Award, Search, UserCheck, TrendingUp, AlertCircle, Clock, Inbox } from 'lucide-react';
|
||
import { backendApi } from '../../services/apiClient';
|
||
|
||
interface OverallStats {
|
||
total: number;
|
||
completed: number;
|
||
overdue: number;
|
||
inProgress: number;
|
||
completionRate: number;
|
||
overdueRate: number;
|
||
}
|
||
|
||
interface EmployeeStats {
|
||
employeeName: string;
|
||
districtName: string | null;
|
||
totalAssigned: number;
|
||
totalCompleted: number;
|
||
totalOverdue: number;
|
||
performanceScore: number;
|
||
}
|
||
|
||
interface DistrictStats {
|
||
districtId: string;
|
||
districtName: string;
|
||
managerName: string;
|
||
totalApplications: number;
|
||
totalCompleted: number;
|
||
totalOverdue: number;
|
||
completionRate: number;
|
||
overdueRate: number;
|
||
averageScore: number;
|
||
}
|
||
|
||
export const QualityControl: React.FC = () => {
|
||
const [overallStats, setOverallStats] = useState<OverallStats | null>(null);
|
||
const [employeeStats, setEmployeeStats] = useState<EmployeeStats[]>([]);
|
||
const [districtStats, setDistrictStats] = useState<DistrictStats[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
const fetchStats = async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [overallRes, employeesRes, districtsRes] = await Promise.all([
|
||
backendApi.getOverallPerformance(),
|
||
backendApi.getEmployeePerformance(),
|
||
backendApi.getDistrictPerformance(),
|
||
]);
|
||
|
||
if (overallRes.success) {
|
||
setOverallStats(overallRes.data);
|
||
}
|
||
if (employeesRes.success) {
|
||
setEmployeeStats(employeesRes.data);
|
||
}
|
||
if (districtsRes.success) {
|
||
setDistrictStats(districtsRes.data);
|
||
}
|
||
} catch (err) {
|
||
console.error('[QualityControl] Ошибка загрузки статистики:', err);
|
||
setError('Не удалось загрузить статистику производительности');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchStats();
|
||
}, []);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-20 animate-pulse">
|
||
<TrendingUp className="w-10 h-10 text-primary-400 animate-spin mb-4" />
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Загрузка статистики...</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="text-center py-20">
|
||
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||
<p className="text-sm font-bold text-red-600">{error}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const bestEmployee = employeeStats.length > 0
|
||
? employeeStats.reduce((best, current) =>
|
||
current.performanceScore > best.performanceScore ? current : best
|
||
)
|
||
: null;
|
||
|
||
// Рассчитываем общий CSAT на основе completionRate и overdueRate
|
||
const csatScore = overallStats
|
||
? Math.max(1, Math.min(5, (overallStats.completionRate / 100) * 5 - (overallStats.overdueRate / 100) * 2))
|
||
: 0;
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{/* Satisfaction Summary */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
<div className="md:col-span-2 bg-white p-8 rounded-[2.5rem] border border-slate-200 shadow-sm flex flex-col justify-between">
|
||
<div className="flex justify-between items-start mb-8">
|
||
<div>
|
||
<h3 className="text-2xl font-black text-slate-800">Качество сервиса</h3>
|
||
<p className="text-xs text-slate-500 font-medium mt-1 uppercase tracking-widest">Индекс производительности</p>
|
||
</div>
|
||
<div className="p-4 bg-amber-50 rounded-2xl text-amber-500">
|
||
<Star className="w-8 h-8 fill-current"/>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-end gap-12">
|
||
<div>
|
||
<p className="text-6xl font-black text-slate-800">{csatScore.toFixed(1)}</p>
|
||
<div className="flex gap-1 mt-2">
|
||
{[1,2,3,4,5].map(i => (
|
||
<Star
|
||
key={i}
|
||
className={`w-4 h-4 ${i <= Math.round(csatScore) ? 'fill-amber-400 text-amber-400' : 'text-slate-200'}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 space-y-3">
|
||
{overallStats && (
|
||
<>
|
||
<RatingBar
|
||
label="Выполнение заявок"
|
||
value={overallStats.completionRate}
|
||
/>
|
||
<RatingBar
|
||
label="Просроченные заявки"
|
||
value={100 - overallStats.overdueRate}
|
||
inverse
|
||
/>
|
||
<RatingBar
|
||
label="В работе"
|
||
value={(overallStats.inProgress / overallStats.total) * 100}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-gradient-to-br from-violet-600 to-indigo-800 p-6 rounded-[2.5rem] text-white shadow-xl relative overflow-hidden">
|
||
<Award className="absolute -bottom-4 -right-4 w-32 h-32 opacity-10 rotate-12" />
|
||
<h4 className="font-black text-xs uppercase tracking-widest mb-6 opacity-70">Лучший мастер месяца</h4>
|
||
{bestEmployee ? (
|
||
<div className="text-center">
|
||
<div className="w-20 h-20 rounded-full bg-white/20 border-4 border-white/20 mx-auto mb-4 flex items-center justify-center text-3xl font-black">
|
||
{bestEmployee.employeeName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()}
|
||
</div>
|
||
<p className="text-lg font-black leading-none">{bestEmployee.employeeName}</p>
|
||
{bestEmployee.districtName && (
|
||
<p className="text-[10px] font-bold uppercase text-indigo-200 mt-2">{bestEmployee.districtName}</p>
|
||
)}
|
||
<div className="mt-6 flex justify-center gap-4">
|
||
<div className="text-center">
|
||
<p className="text-xl font-black">{bestEmployee.totalCompleted}</p>
|
||
<p className="text-[8px] font-bold uppercase opacity-50">Выполнено</p>
|
||
</div>
|
||
<div className="w-px h-8 bg-white/10" />
|
||
<div className="text-center">
|
||
<p className="text-xl font-black">{bestEmployee.performanceScore.toFixed(1)}</p>
|
||
<p className="text-[8px] font-bold uppercase opacity-50">Рейтинг</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-center text-indigo-200">
|
||
<p className="text-sm">Нет данных</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Статистика по участкам */}
|
||
{districtStats.length > 0 && (
|
||
<div className="space-y-4">
|
||
<h4 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">Статистика по участкам</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{districtStats.map((district) => (
|
||
<DistrictCard key={district.districtId} district={district} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Топ сотрудников */}
|
||
{employeeStats.length > 0 && (
|
||
<div className="space-y-4">
|
||
<h4 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">Рейтинг сотрудников</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{employeeStats
|
||
.sort((a, b) => b.performanceScore - a.performanceScore)
|
||
.slice(0, 6)
|
||
.map((employee, index) => (
|
||
<EmployeeCard key={employee.employeeName} employee={employee} rank={index + 1} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Общая статистика */}
|
||
{overallStats && (
|
||
<div className="bg-white p-6 rounded-[2.5rem] border border-slate-200 shadow-sm">
|
||
<h4 className="font-black text-slate-800 text-sm mb-4">Общая статистика</h4>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<StatCard
|
||
label="Всего заявок"
|
||
value={overallStats.total}
|
||
icon={<Inbox className="w-5 h-5" />}
|
||
/>
|
||
<StatCard
|
||
label="Выполнено"
|
||
value={overallStats.completed}
|
||
icon={<TrendingUp className="w-5 h-5 text-emerald-500" />}
|
||
color="emerald"
|
||
/>
|
||
<StatCard
|
||
label="Просрочено"
|
||
value={overallStats.overdue}
|
||
icon={<AlertCircle className="w-5 h-5 text-red-500" />}
|
||
color="red"
|
||
/>
|
||
<StatCard
|
||
label="В работе"
|
||
value={overallStats.inProgress}
|
||
icon={<Clock className="w-5 h-5 text-blue-500" />}
|
||
color="blue"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const RatingBar = ({ label, value, inverse = false }: { label: string; value: number; inverse?: boolean }) => {
|
||
const displayValue = Math.max(0, Math.min(100, value));
|
||
const color = inverse ? (displayValue < 50 ? 'bg-red-500' : displayValue < 80 ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-primary-500';
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between text-[10px] font-black uppercase text-slate-400">
|
||
<span>{label}</span>
|
||
<span className="text-slate-700">{displayValue.toFixed(1)}%</span>
|
||
</div>
|
||
<div className="h-1 w-full bg-slate-100 rounded-full">
|
||
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${displayValue}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DistrictCard = ({ district }: { district: DistrictStats }) => (
|
||
<div className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm">
|
||
<div className="flex justify-between items-start mb-3">
|
||
<div>
|
||
<h5 className="font-black text-slate-800 text-sm leading-none">{district.districtName}</h5>
|
||
<p className="text-[10px] text-slate-400 font-bold mt-1 uppercase tracking-tight">{district.managerName}</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-2xl font-black text-primary-600">{district.averageScore.toFixed(1)}</p>
|
||
<p className="text-[8px] font-bold uppercase text-slate-400">Рейтинг</p>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2 mt-4">
|
||
<div className="flex justify-between text-[10px] font-bold text-slate-600">
|
||
<span>Выполнено:</span>
|
||
<span>{district.totalCompleted} / {district.totalApplications}</span>
|
||
</div>
|
||
<div className="flex justify-between text-[10px] font-bold text-slate-600">
|
||
<span>Просрочено:</span>
|
||
<span className={district.totalOverdue > 0 ? 'text-red-600' : ''}>{district.totalOverdue}</span>
|
||
</div>
|
||
<div className="h-1 w-full bg-slate-100 rounded-full">
|
||
<div
|
||
className="h-full bg-emerald-500 rounded-full"
|
||
style={{ width: `${district.completionRate}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const EmployeeCard = ({ employee, rank }: { employee: EmployeeStats; rank: number }) => (
|
||
<div className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm flex gap-4">
|
||
<div className={`w-12 h-12 rounded-2xl flex-shrink-0 flex items-center justify-center font-black text-white ${
|
||
rank === 1 ? 'bg-amber-500' : rank === 2 ? 'bg-slate-400' : rank === 3 ? 'bg-orange-500' : 'bg-primary-500'
|
||
}`}>
|
||
{rank}
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex justify-between items-start mb-1">
|
||
<div>
|
||
<h5 className="font-black text-slate-800 text-sm leading-none">{employee.employeeName}</h5>
|
||
{employee.districtName && (
|
||
<p className="text-[10px] text-slate-400 font-bold mt-1 uppercase tracking-tight">{employee.districtName}</p>
|
||
)}
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-lg font-black text-primary-600">{employee.performanceScore.toFixed(1)}</p>
|
||
<p className="text-[8px] font-bold uppercase text-slate-400">Рейтинг</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-4 mt-2 text-[10px] font-bold text-slate-600">
|
||
<span>Выполнено: <span className="text-emerald-600">{employee.totalCompleted}</span></span>
|
||
<span>Просрочено: <span className={employee.totalOverdue > 0 ? 'text-red-600' : ''}>{employee.totalOverdue}</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const StatCard = ({ label, value, icon, color = 'primary' }: {
|
||
label: string;
|
||
value: number;
|
||
icon: React.ReactNode;
|
||
color?: 'primary' | 'emerald' | 'red' | 'blue';
|
||
}) => {
|
||
const colorClasses = {
|
||
primary: 'text-primary-500',
|
||
emerald: 'text-emerald-500',
|
||
red: 'text-red-500',
|
||
blue: 'text-blue-500',
|
||
};
|
||
|
||
return (
|
||
<div className="text-center">
|
||
<div className={`inline-flex p-3 rounded-2xl mb-2 ${colorClasses[color].replace('text-', 'bg-').replace('-500', '-50')}`}>
|
||
{icon}
|
||
</div>
|
||
<p className="text-2xl font-black text-slate-800">{value}</p>
|
||
<p className="text-[10px] font-bold uppercase text-slate-400 mt-1">{label}</p>
|
||
</div>
|
||
);
|
||
};
|