197 lines
8.9 KiB
TypeScript
197 lines
8.9 KiB
TypeScript
|
|
|
|||
|
|
import React, { useMemo } from 'react';
|
|||
|
|
import {
|
|||
|
|
Inbox,
|
|||
|
|
Clock,
|
|||
|
|
CheckCircle2,
|
|||
|
|
AlertTriangle,
|
|||
|
|
TrendingUp,
|
|||
|
|
Activity,
|
|||
|
|
Users,
|
|||
|
|
Zap
|
|||
|
|
} from 'lucide-react';
|
|||
|
|
import { DomaApplication } from '../../types';
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
applications: DomaApplication[];
|
|||
|
|
onNavigate: (tab: any) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const AppSummary: React.FC<Props> = ({ applications, onNavigate }) => {
|
|||
|
|
// Используем поле isOverdue из БД, если есть, иначе вычисляем
|
|||
|
|
const stats = {
|
|||
|
|
new: applications.filter(a => a.status === 'new').length,
|
|||
|
|
inProgress: applications.filter(a => a.status === 'in_progress').length,
|
|||
|
|
deferred: applications.filter(a => a.status === 'deferred').length,
|
|||
|
|
done: applications.filter(a => a.status === 'done').length,
|
|||
|
|
overdue: applications.filter(a =>
|
|||
|
|
a.isOverdue !== undefined
|
|||
|
|
? a.isOverdue
|
|||
|
|
: (a.status !== 'done' && a.status !== 'canceled' && new Date(a.deadlineAt) < new Date())
|
|||
|
|
).length
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const totalActive = stats.new + stats.inProgress + stats.deferred;
|
|||
|
|
const completionRate = applications.length > 0 ? Math.round((stats.done / (stats.done + totalActive)) * 100) : 0;
|
|||
|
|
|
|||
|
|
// Группируем заявки по исполнителям для "Загрузка мастеров"
|
|||
|
|
const performerStats = useMemo(() => {
|
|||
|
|
const map = new Map<string, number>();
|
|||
|
|
applications.forEach(app => {
|
|||
|
|
if (app.performer?.name && app.status !== 'done' && app.status !== 'canceled') {
|
|||
|
|
const count = map.get(app.performer.name) || 0;
|
|||
|
|
map.set(app.performer.name, count + 1);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
return Array.from(map.entries())
|
|||
|
|
.map(([name, count]) => ({ name, count }))
|
|||
|
|
.sort((a, b) => b.count - a.count)
|
|||
|
|
.slice(0, 4); // Топ-4 исполнителя
|
|||
|
|
}, [applications]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6 animate-fade-in">
|
|||
|
|
{/* Real-time KPI Cards */}
|
|||
|
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
|||
|
|
<StatCard
|
|||
|
|
icon={Inbox}
|
|||
|
|
label="Новые"
|
|||
|
|
value={stats.new}
|
|||
|
|
color="text-red-600"
|
|||
|
|
bg="bg-red-50"
|
|||
|
|
onClick={() => onNavigate('registry')}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={Activity}
|
|||
|
|
label="В работе"
|
|||
|
|
value={stats.inProgress}
|
|||
|
|
color="text-blue-600"
|
|||
|
|
bg="bg-blue-50"
|
|||
|
|
onClick={() => onNavigate('registry')}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={Clock}
|
|||
|
|
label="Отложено"
|
|||
|
|
value={stats.deferred}
|
|||
|
|
color="text-orange-600"
|
|||
|
|
bg="bg-orange-50"
|
|||
|
|
onClick={() => onNavigate('registry')}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={CheckCircle2}
|
|||
|
|
label="Выполнено"
|
|||
|
|
value={stats.done}
|
|||
|
|
color="text-emerald-600"
|
|||
|
|
bg="bg-emerald-50"
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={AlertTriangle}
|
|||
|
|
label="Просрочено"
|
|||
|
|
value={stats.overdue}
|
|||
|
|
color="text-amber-600"
|
|||
|
|
bg="bg-amber-50"
|
|||
|
|
onClick={() => onNavigate('control')}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|||
|
|
{/* Main Progress Widget */}
|
|||
|
|
<div className="lg:col-span-2 bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden">
|
|||
|
|
<Zap className="absolute -top-4 -right-4 w-40 h-40 opacity-10 rotate-12 text-primary-400" />
|
|||
|
|
<div className="relative z-10">
|
|||
|
|
<div className="flex justify-between items-start mb-8">
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-3xl font-black mb-2">Производительность</h3>
|
|||
|
|
<p className="text-slate-400 text-sm font-medium">Эффективность закрытия заявок за сегодня</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="text-right">
|
|||
|
|
<p className="text-5xl font-black text-primary-400">{completionRate}%</p>
|
|||
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mt-1">Индекс SLA</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<StatusLine
|
|||
|
|
label="Новые заявки"
|
|||
|
|
value={applications.length > 0 ? Math.round((stats.new / applications.length) * 100) : 0}
|
|||
|
|
color="bg-red-500"
|
|||
|
|
/>
|
|||
|
|
<StatusLine
|
|||
|
|
label="В работе"
|
|||
|
|
value={applications.length > 0 ? Math.round((stats.inProgress / applications.length) * 100) : 0}
|
|||
|
|
color="bg-blue-500"
|
|||
|
|
/>
|
|||
|
|
<StatusLine
|
|||
|
|
label="Выполнено"
|
|||
|
|
value={completionRate}
|
|||
|
|
color="bg-emerald-500"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Performer Load */}
|
|||
|
|
<div className="bg-white rounded-[2.5rem] p-6 border border-slate-200 shadow-sm">
|
|||
|
|
<h4 className="font-black text-slate-800 text-[10px] uppercase tracking-widest mb-6 flex items-center gap-2">
|
|||
|
|
<Users className="w-4 h-4 text-primary-500"/> Загрузка мастеров
|
|||
|
|
</h4>
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{performerStats.length > 0 ? (
|
|||
|
|
performerStats.map((perf, idx) => (
|
|||
|
|
<PerformerItem
|
|||
|
|
key={idx}
|
|||
|
|
name={perf.name}
|
|||
|
|
count={perf.count}
|
|||
|
|
warning={perf.count > 5}
|
|||
|
|
/>
|
|||
|
|
))
|
|||
|
|
) : (
|
|||
|
|
<p className="text-xs text-slate-400 italic text-center py-4">Нет активных заявок</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<button onClick={() => onNavigate('control')} className="w-full mt-6 py-3 bg-slate-50 hover:bg-slate-100 rounded-2xl text-[10px] font-black uppercase tracking-widest text-slate-500 transition-colors">
|
|||
|
|
Перераспределить
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const StatCard = ({ icon: Icon, label, value, color, bg, onClick }: any) => (
|
|||
|
|
<div
|
|||
|
|
onClick={onClick}
|
|||
|
|
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm cursor-pointer hover:border-primary-400 transition-all hover:shadow-md active:scale-95"
|
|||
|
|
>
|
|||
|
|
<div className="flex justify-between items-start mb-3">
|
|||
|
|
<div className={`p-2.5 ${bg} ${color} rounded-2xl`}>
|
|||
|
|
<Icon className="w-5 h-5"/>
|
|||
|
|
</div>
|
|||
|
|
{value > 5 && label === 'Новые' && <span className="w-2 h-2 rounded-full bg-red-500 animate-ping"/>}
|
|||
|
|
</div>
|
|||
|
|
<p className="text-3xl font-black text-slate-800 leading-none">{value}</p>
|
|||
|
|
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider mt-2">{label}</p>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const StatusLine = ({ label, value, color }: any) => (
|
|||
|
|
<div className="space-y-1.5">
|
|||
|
|
<div className="flex justify-between text-[10px] font-bold uppercase text-slate-400">
|
|||
|
|
<span>{label}</span>
|
|||
|
|
<span className="text-white">{value}%</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden">
|
|||
|
|
<div className={`h-full ${color} rounded-full transition-all duration-1000`} style={{ width: `${value}%` }} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const PerformerItem = ({ name, count, warning }: any) => (
|
|||
|
|
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-2xl border border-slate-100">
|
|||
|
|
<span className="text-xs font-bold text-slate-700 truncate mr-2">{name}</span>
|
|||
|
|
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-black ${warning ? 'bg-red-100 text-red-600' : 'bg-primary-100 text-primary-600'}`}>
|
|||
|
|
{count} заяв.
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
);
|