Files
ln-bi/src/modules/mileage/MileageAttributionView.tsx
T

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>
);
}