193 lines
9.3 KiB
TypeScript
193 lines
9.3 KiB
TypeScript
import type { ReactNode } from 'react';
|
||
import { AnimatePresence, motion } from 'motion/react';
|
||
import {
|
||
ChevronDown,
|
||
CircleGauge,
|
||
MapPin,
|
||
Truck,
|
||
} from 'lucide-react';
|
||
import type { MileageReportGroup } from '../api';
|
||
import VehicleTable from './VehicleTable';
|
||
import {
|
||
currentAssessmentYearLabel,
|
||
dailyMileageStatus,
|
||
fmtKm,
|
||
} from './utils';
|
||
|
||
interface GroupRowProps {
|
||
group: MileageReportGroup;
|
||
reportDate: string;
|
||
expanded: boolean;
|
||
onToggle: () => void;
|
||
}
|
||
|
||
function BreakdownList({
|
||
title,
|
||
icon,
|
||
items,
|
||
showAverage = true,
|
||
}: {
|
||
title: string;
|
||
icon: ReactNode;
|
||
items: MileageReportGroup['departments'];
|
||
showAverage?: boolean;
|
||
}) {
|
||
const max = Math.max(1, ...items.map(item => item.vehicleCount));
|
||
return (
|
||
<div className="min-w-0">
|
||
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
|
||
<span className="text-slate-400">{icon}</span>
|
||
{title}
|
||
</div>
|
||
{items.length === 0 ? (
|
||
<div className="rounded-lg bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无数据</div>
|
||
) : (
|
||
<div className="space-y-2.5">
|
||
{items.map(item => (
|
||
<div key={item.name}>
|
||
<div className="mb-1 flex items-center justify-between gap-3 text-[11px] font-bold">
|
||
<span className="truncate text-slate-600">{item.name}</span>
|
||
<span className="shrink-0 tabular-nums text-slate-500">
|
||
{item.vehicleCount} 台{showAverage ? ` · 均 ${fmtKm(item.averageMileage)} km` : ''}
|
||
</span>
|
||
</div>
|
||
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||
<div
|
||
className="h-full rounded-full bg-blue-500"
|
||
style={{ width: `${Math.max(4, (item.vehicleCount / max) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function GroupRow({
|
||
group,
|
||
reportDate,
|
||
expanded,
|
||
onToggle,
|
||
}: GroupRowProps) {
|
||
const highRate = group.operatingCount > 0 ? (group.highMileageCount / group.operatingCount) * 100 : 0;
|
||
const qualifiedRate = group.vehicleCount > 0 ? (group.qualifiedCount / group.vehicleCount) * 100 : 0;
|
||
const assessmentYearLabel = currentAssessmentYearLabel(group.assessmentPeriods, reportDate);
|
||
const dailyStatus = dailyMileageStatus(group);
|
||
const dailyStatusClass = dailyStatus.tone === 'positive'
|
||
? 'border-emerald-100 bg-emerald-50/70 text-emerald-700'
|
||
: dailyStatus.tone === 'warning'
|
||
? 'border-amber-100 bg-amber-50/70 text-amber-700'
|
||
: 'border-slate-100 bg-slate-50 text-slate-600';
|
||
|
||
return (
|
||
<div className="border-b border-slate-100 last:border-0">
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
className="grid w-full grid-cols-[minmax(170px,1.3fr)_82px_100px_88px_100px_96px_24px] items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-50 max-lg:grid-cols-[minmax(150px,1fr)_82px_96px_24px]"
|
||
aria-expanded={expanded}
|
||
>
|
||
<span className="min-w-0">
|
||
<span className="flex min-w-0 items-center gap-1.5">
|
||
<span className="truncate text-xs font-black text-slate-900">{group.displayName}</span>
|
||
{assessmentYearLabel ? (
|
||
<span
|
||
className="shrink-0 rounded-md bg-blue-50 px-1.5 py-0.5 text-[9px] font-black text-blue-700 ring-1 ring-blue-100"
|
||
title={`当前考核年度:${assessmentYearLabel}`}
|
||
>
|
||
{assessmentYearLabel}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<span className="mt-1 flex flex-wrap items-center gap-x-1 text-[10px] font-bold text-slate-400">
|
||
<span>{group.vehicleCount} 台 · 运营 {group.operatingCount} · 库存 {group.inventoryCount}</span>
|
||
<span className="text-emerald-600">· 达标 {group.qualifiedCount} 台</span>
|
||
</span>
|
||
</span>
|
||
<span className="text-right">
|
||
<span className="block text-xs font-black tabular-nums text-blue-600">{group.dailyMileage}</span>
|
||
<span className="text-[9px] font-bold text-slate-400">km</span>
|
||
</span>
|
||
<span className="text-right max-lg:hidden">
|
||
<span className={`block text-xs font-black tabular-nums ${group.dayOverDayDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||
{group.dayOverDayDelta >= 0 ? '+' : ''}{fmtKm(group.dayOverDayDelta)}
|
||
</span>
|
||
<span className="text-[9px] font-bold text-slate-400">环比变化</span>
|
||
</span>
|
||
<span className="text-right max-lg:hidden">
|
||
<span className="block text-xs font-black tabular-nums text-slate-700">{group.lowMileageCount}/{group.highMileageCount}</span>
|
||
<span className="text-[9px] font-bold text-slate-400">低/高里程</span>
|
||
</span>
|
||
<span className="text-right">
|
||
<span className="block text-xs font-black tabular-nums text-slate-700">{highRate.toFixed(1)}%</span>
|
||
<span className="text-[9px] font-bold text-slate-400">高里程占比</span>
|
||
</span>
|
||
<span className="text-right max-lg:hidden">
|
||
<span className="block text-xs font-black tabular-nums text-slate-700">{group.qualifiedCount}</span>
|
||
<span className="text-[9px] font-bold text-slate-400">年度达标</span>
|
||
</span>
|
||
<ChevronDown size={16} className={`text-slate-400 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||
</button>
|
||
|
||
<AnimatePresence initial={false}>
|
||
{expanded ? (
|
||
<motion.div
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
className="overflow-hidden"
|
||
>
|
||
<div className="bg-slate-50/70 px-4 py-4">
|
||
<div className="grid gap-4 xl:grid-cols-[1fr_1fr_1.1fr]">
|
||
<BreakdownList title="业务部门" icon={<Truck size={15} />} items={group.departments} />
|
||
<BreakdownList title="库存地区" icon={<MapPin size={15} />} items={group.inventoryRegions} showAverage={false} />
|
||
<div>
|
||
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
|
||
<CircleGauge size={15} className="text-slate-400" />考核进度
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||
<div className="text-[10px] font-bold text-slate-400">累计完成率</div>
|
||
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.completionRate.toFixed(1)}%</div>
|
||
</div>
|
||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||
<div className="text-[10px] font-bold text-slate-400">达标50%</div>
|
||
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.halfQualifiedCount} 台</div>
|
||
</div>
|
||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||
<div className="text-[10px] font-bold text-slate-400">剩余任务</div>
|
||
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{fmtKm(group.remainingMileage)} km</div>
|
||
</div>
|
||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||
<div className="text-[10px] font-bold text-slate-400">每日应完成</div>
|
||
<div className="mt-1 text-base font-black tabular-nums text-blue-600">{fmtKm(group.dailyRequiredMileage)} km</div>
|
||
</div>
|
||
<div className={`col-span-2 flex flex-wrap items-end justify-between gap-x-4 gap-y-2 rounded-lg border p-3 ${dailyStatusClass}`}>
|
||
<div>
|
||
<div className="text-[10px] font-black">当日目标执行 · {dailyStatus.label}</div>
|
||
<div className="mt-1 text-[10px] font-bold opacity-80">当日实际 {fmtKm(group.dailyMileage)} km / 应完成 {fmtKm(group.dailyRequiredMileage)} km</div>
|
||
</div>
|
||
<div className="max-w-[260px] text-[10px] font-bold leading-relaxed opacity-80">{dailyStatus.detail}</div>
|
||
</div>
|
||
<div className="col-span-2 flex items-end justify-between gap-3 rounded-lg border border-blue-100 bg-blue-50/50 p-3">
|
||
<div>
|
||
<div className="text-[10px] font-bold text-slate-500">累计达标率</div>
|
||
<div className="mt-1 text-[10px] font-bold text-slate-500">已达标 {group.qualifiedCount} / {group.vehicleCount} 台</div>
|
||
</div>
|
||
<div className="text-lg font-black tabular-nums text-blue-700">{qualifiedRate.toFixed(1)}%</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 text-[10px] font-bold leading-relaxed text-slate-400">{group.assessmentPeriods.join(';')}</div>
|
||
</div>
|
||
</div>
|
||
<VehicleTable group={group} />
|
||
</div>
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|