130 lines
5.5 KiB
TypeScript
130 lines
5.5 KiB
TypeScript
import { AlertTriangle, Building2, ChevronRight, Users } from 'lucide-react';
|
|
import type { TargetVehicle } from './types';
|
|
import {
|
|
buildMileageAttribution,
|
|
reconcileMileageAttribution,
|
|
type AttributionDimension,
|
|
} from './attribution';
|
|
|
|
interface Props {
|
|
vehicles: TargetVehicle[];
|
|
dimension: AttributionDimension;
|
|
selectedValue?: string;
|
|
loading?: boolean;
|
|
targetMileagePerVehicle: number;
|
|
expectedShortfallMileage?: number;
|
|
assessmentLabel?: string;
|
|
onDimensionChange: (dimension: AttributionDimension) => void;
|
|
onSelect: (value: string) => void;
|
|
}
|
|
|
|
function fmtKm(value: number): string {
|
|
return value >= 10000 ? `${(value / 10000).toFixed(2)}万` : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
|
}
|
|
|
|
export default function MileageAttributionView({
|
|
vehicles,
|
|
dimension,
|
|
selectedValue,
|
|
loading = false,
|
|
targetMileagePerVehicle,
|
|
expectedShortfallMileage,
|
|
assessmentLabel,
|
|
onDimensionChange,
|
|
onSelect,
|
|
}: Props) {
|
|
const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
|
|
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
|
|
const totalBehind = groups.reduce((sum, group) => sum + group.behindCount, 0);
|
|
const reconciliation = expectedShortfallMileage == null
|
|
? null
|
|
: reconcileMileageAttribution(groups, expectedShortfallMileage);
|
|
|
|
return (
|
|
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
<div className="flex flex-col gap-3 border-b border-slate-100 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="h-4 w-1 rounded-full bg-blue-600" />
|
|
<h2 className="text-sm font-black text-slate-800">考核缺口归因</h2>
|
|
</div>
|
|
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
|
{assessmentLabel ? `${assessmentLabel} · ` : ''}{totalBehind} 台未达标,累计缺口 {fmtKm(totalShortfall)} km
|
|
</p>
|
|
{!loading && reconciliation && (
|
|
<p
|
|
role={reconciliation.matches ? 'status' : 'alert'}
|
|
className={`mt-1 text-[10px] font-black ${reconciliation.matches ? 'text-emerald-600' : 'text-rose-600'}`}
|
|
>
|
|
{reconciliation.matches
|
|
? '与考核目标汇总一致'
|
|
: `与考核目标汇总相差 ${reconciliation.difference > 0 ? '+' : ''}${fmtKm(reconciliation.difference)} km`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex rounded-lg bg-slate-100 p-1" aria-label="归因维度">
|
|
{([
|
|
['department', '部门', Building2],
|
|
['customer', '客户', Users],
|
|
] as const).map(([value, label, Icon]) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => onDimensionChange(value)}
|
|
className={`flex min-w-[72px] items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-[10px] font-black transition-colors ${
|
|
dimension === value ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
|
}`}
|
|
>
|
|
<Icon size={12} />
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">正在计算归因...</div>
|
|
) : groups.length === 0 ? (
|
|
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">暂无可归因车辆</div>
|
|
) : (
|
|
<div className="divide-y divide-slate-100">
|
|
{groups.slice(0, 8).map(group => (
|
|
<button
|
|
key={group.key}
|
|
type="button"
|
|
onClick={() => onSelect(group.key)}
|
|
className={`grid w-full grid-cols-[minmax(0,1fr)_70px_82px_24px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50 ${
|
|
selectedValue === group.key ? 'bg-blue-50/60' : ''
|
|
}`}
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-xs font-black text-slate-800">{group.label}</span>
|
|
<span className="mt-0.5 flex items-center gap-2 text-[9px] font-bold text-slate-400">
|
|
<span>{group.vehicleCount} 台</span>
|
|
<span>{group.onlineCount} 台在线</span>
|
|
{group.zeroMileageCount > 0 && (
|
|
<span className="flex items-center gap-0.5 text-amber-600">
|
|
<AlertTriangle size={9} /> {group.zeroMileageCount} 台零里程
|
|
</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
<span className="text-right">
|
|
<span className="block text-[11px] font-black text-slate-800">{fmtKm(group.todayMileage)}</span>
|
|
<span className="block text-[9px] font-bold text-slate-400">贡献 {group.contributionRate.toFixed(1)}%</span>
|
|
</span>
|
|
<span className="text-right">
|
|
<span className={`block text-[11px] font-black ${group.shortfallMileage > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
|
|
{fmtKm(group.shortfallMileage)}
|
|
</span>
|
|
<span className="block text-[9px] font-bold text-slate-400">累计缺口 · {group.behindCount} 台</span>
|
|
</span>
|
|
<ChevronRight size={14} className="text-slate-300" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|