Files
mkd/components/applications/QualityControl.tsx
2026-02-04 00:17:04 +05:00

344 lines
14 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};