Files
mkd/components/applications/QualityControl.tsx

344 lines
14 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};