Initial commit MKD fixes
This commit is contained in:
343
components/applications/QualityControl.tsx
Executable file
343
components/applications/QualityControl.tsx
Executable file
@@ -0,0 +1,343 @@
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user