feat: polish BI dashboards and bump version
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -14,8 +14,12 @@ import {
|
||||
Filter,
|
||||
ArrowRightLeft,
|
||||
MapPin,
|
||||
Download,
|
||||
CalendarDays,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -30,12 +34,13 @@ import {
|
||||
LabelList,
|
||||
} from 'recharts';
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api';
|
||||
import type { WeeklyDetailItem } from './api';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api';
|
||||
import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
|
||||
// --- Constants ---
|
||||
@@ -92,6 +97,33 @@ function formatLocalDateTime(date: Date): string {
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function formatLocalDate(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getWeeklyFlowRange() {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const end = day === 6 ? addDays(now, -1) : day === 0 ? addDays(now, -2) : addDays(now, 5 - day);
|
||||
const start = addDays(end, -6);
|
||||
return { start: formatLocalDate(start), end: formatLocalDate(end) };
|
||||
}
|
||||
|
||||
const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
|
||||
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
|
||||
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
|
||||
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
|
||||
};
|
||||
|
||||
export default function AssetsModule() {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
||||
const [tabReady, setTabReady] = useState(true);
|
||||
@@ -140,6 +172,11 @@ export default function AssetsModule() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date()));
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [flowRange, setFlowRange] = useState(() => getWeeklyFlowRange());
|
||||
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
|
||||
const [flowLoading, setFlowLoading] = useState(false);
|
||||
const [flowDailyExpanded, setFlowDailyExpanded] = useState(false);
|
||||
const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null);
|
||||
|
||||
// Dept/Region/Customer data
|
||||
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
|
||||
@@ -222,6 +259,24 @@ export default function AssetsModule() {
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFlowLoading(true);
|
||||
fetchFlowStats({ start: flowRange.start, end: flowRange.end, subject: selectedSubject })
|
||||
.then((data) => {
|
||||
if (!cancelled) setFlowStats(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFlowStats(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setFlowLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [flowRange.start, flowRange.end, selectedSubject]);
|
||||
|
||||
// 归属公司列表(仅首次加载,公司集合相对稳定)
|
||||
useEffect(() => {
|
||||
fetchSubjects().then(setSubjects).catch(() => setSubjects([]));
|
||||
@@ -521,6 +576,40 @@ export default function AssetsModule() {
|
||||
return mp;
|
||||
}), [modalWeeklyDetail, modalFilters.plateNumber]);
|
||||
|
||||
const selectedFlowDetails = useMemo(() => {
|
||||
if (!flowStats || !selectedFlow) return [];
|
||||
return flowStats.details.filter((item) => item.date === selectedFlow.date && item.type === selectedFlow.type);
|
||||
}, [flowStats, selectedFlow]);
|
||||
|
||||
const exportFlowDetails = useCallback((rows?: FlowDetailItem[], title = '资产流转明细') => {
|
||||
const source = rows ?? flowStats?.details ?? [];
|
||||
if (source.length === 0) return;
|
||||
const table = source.map((item) => ({
|
||||
日期: item.date,
|
||||
类型: item.typeLabel,
|
||||
车牌: item.plateNumber,
|
||||
流转时间: item.eventTime || '',
|
||||
提交时间: item.submitTime || '',
|
||||
部门: item.department || '',
|
||||
业务负责人: item.manager || '',
|
||||
客户: item.customerName || '',
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(table);
|
||||
ws['!cols'] = [
|
||||
{ wch: 14 },
|
||||
{ wch: 8 },
|
||||
{ wch: 14 },
|
||||
{ wch: 20 },
|
||||
{ wch: 20 },
|
||||
{ wch: 16 },
|
||||
{ wch: 14 },
|
||||
{ wch: 24 },
|
||||
];
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '明细');
|
||||
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
|
||||
}, [flowRange.end, flowRange.start, flowStats]);
|
||||
|
||||
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
|
||||
useEffect(() => {
|
||||
if (customerChartView === 'province') {
|
||||
@@ -541,35 +630,57 @@ export default function AssetsModule() {
|
||||
|
||||
if (loading && !summary) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="animate-spin text-blue-500" size={32} />
|
||||
<span className="text-sm text-gray-500">正在加载数据...</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageFrame
|
||||
title="车辆资产中心"
|
||||
subtitle="正在同步车辆、部门、区域与客户归属数据,加载完成后可继续穿透查看明细。"
|
||||
icon={Truck}
|
||||
eyebrow="ASSET INTELLIGENCE"
|
||||
meta="数据准备中"
|
||||
>
|
||||
<SurfaceCard className="min-h-[360px]">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[0, 1, 2, 3].map(item => (
|
||||
<SkeletonBlock key={item} className="h-28" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<LoadingState label="正在加载车辆资产数据" />
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !summary) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="text-red-500 text-lg font-bold">加载失败</div>
|
||||
<div className="text-sm text-gray-500">{error}</div>
|
||||
<button onClick={loadData} className="mt-2 px-4 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageFrame
|
||||
title="车辆资产中心"
|
||||
subtitle="车辆资产数据暂时没有返回,请重试或稍后再看。"
|
||||
icon={Truck}
|
||||
eyebrow="ASSET INTELLIGENCE"
|
||||
meta="加载失败"
|
||||
>
|
||||
<SurfaceCard className="min-h-[360px]">
|
||||
<div className="mt-6 flex flex-col items-center gap-4">
|
||||
<ErrorState message={error} />
|
||||
<button onClick={loadData} className="rounded-xl bg-slate-900 px-4 py-2 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800">
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const SUMMARY = summary!;
|
||||
const operatingRate = SUMMARY.totalAssets > 0 ? SUMMARY.operating.total / SUMMARY.totalAssets * 100 : 0;
|
||||
const inventoryRate = SUMMARY.totalAssets > 0 ? SUMMARY.inventory.total / SUMMARY.totalAssets * 100 : 0;
|
||||
const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-6 relative">
|
||||
<div className="min-h-screen bg-[var(--app-bg)] text-gray-800 font-sans p-3 md:p-6 relative">
|
||||
{/* Compact Header Bar */}
|
||||
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
|
||||
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
|
||||
{/* Title row */}
|
||||
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
||||
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
|
||||
@@ -753,11 +864,11 @@ export default function AssetsModule() {
|
||||
{tabReady && activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Header Summary - Ultra Compact */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 mb-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
|
||||
{/* Total Assets */}
|
||||
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
|
||||
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-400">
|
||||
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||
<Truck size={14} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -767,9 +878,9 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
|
||||
{/* Operating */}
|
||||
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
|
||||
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<Activity size={14} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -782,9 +893,9 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
|
||||
{/* Inventory */}
|
||||
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||
<div className="w-7 h-7 rounded bg-gray-50 flex items-center justify-center text-gray-500">
|
||||
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||
<Warehouse size={14} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -797,9 +908,9 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
|
||||
{/* Pending */}
|
||||
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm flex items-center gap-2 cursor-pointer hover:bg-blue-50 transition-colors"
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
|
||||
<div className="w-7 h-7 rounded bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<PlusCircle size={14} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -808,41 +919,161 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamics */}
|
||||
<div className="bg-white p-2 rounded-sm border border-gray-100 shadow-sm col-span-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="text-[9px] text-gray-400 font-bold uppercase tracking-tight">本周动态</div>
|
||||
<div className="text-[7px] text-gray-300 font-normal italic">上周六-本周五</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-1">
|
||||
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-green-50 py-1 rounded transition-all group">
|
||||
<span className="text-xs font-bold text-gray-800 group-hover:text-green-600">{SUMMARY.weeklyNew}</span>
|
||||
<span className="text-[8px] text-green-500/80 font-bold mt-0.5">新增</span>
|
||||
</div>
|
||||
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="w-[1px] h-3 bg-gray-100"></div>
|
||||
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-blue-50 py-1 rounded transition-all group"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Delivered', source: 'asset', title: '本周交车' })}>
|
||||
<span className="text-xs font-bold text-gray-800 group-hover:text-blue-600">{SUMMARY.weeklyDelivered}</span>
|
||||
<span className="text-[8px] text-blue-500/80 font-bold mt-0.5">交车</span>
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="w-[1px] h-3 bg-gray-100"></div>
|
||||
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-orange-50 py-1 rounded transition-all group"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Returned', source: 'asset', title: '本周还车' })}>
|
||||
<span className="text-xs font-bold text-gray-800 group-hover:text-orange-600">{SUMMARY.weeklyReturned}</span>
|
||||
<span className="text-[8px] text-orange-500/80 font-bold mt-0.5">还车</span>
|
||||
</div>
|
||||
<div className="w-[1px] h-3 bg-gray-100"></div>
|
||||
<div className="flex-1 flex flex-col items-center cursor-pointer hover:bg-purple-50 py-1 rounded transition-all group"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Replaced', source: 'asset', title: '本周替换' })}>
|
||||
<span className="text-xs font-bold text-gray-800 group-hover:text-purple-600">{SUMMARY.weeklyReplaced}</span>
|
||||
<span className="text-[8px] text-purple-500/80 font-bold mt-0.5">替换</span>
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">待交率</div>
|
||||
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 mb-4">
|
||||
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
|
||||
<CalendarDays size={14} className="text-blue-500" />
|
||||
<span>交还车统计</span>
|
||||
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] font-bold text-slate-400">默认上周六-本周五 · 提交时间口径</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportFlowDetails()}
|
||||
disabled={!flowStats?.details.length}
|
||||
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Download size={13} />
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="relative cursor-pointer rounded-xl border border-slate-100 bg-slate-50 px-2.5 py-1.5 transition hover:border-blue-100 hover:bg-blue-50/50">
|
||||
<span className="block text-[9px] font-black text-slate-400">开始</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={13} className="text-slate-400" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.start}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
<label className="relative cursor-pointer rounded-xl border border-slate-100 bg-slate-50 px-2.5 py-1.5 transition hover:border-blue-100 hover:bg-blue-50/50">
|
||||
<span className="block text-[9px] font-black text-slate-400">结束</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={13} className="text-slate-400" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.end}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
|
||||
<div className="grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
|
||||
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||
</div>
|
||||
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => (
|
||||
<div key={type} className="border-l border-slate-200/70 px-2">
|
||||
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
|
||||
{flowStats?.totals[type] ?? 0}
|
||||
</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="asset-flow-daily-toggle"
|
||||
onClick={() => setFlowDailyExpanded((prev) => !prev)}
|
||||
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
|
||||
>
|
||||
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
|
||||
<span className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||
{flowStats?.daily.length ?? 0} 天
|
||||
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{flowDailyExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
|
||||
{flowLoading && (
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">正在加载交还车统计...</div>
|
||||
)}
|
||||
{!flowLoading && flowStats?.daily.map((day) => (
|
||||
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
|
||||
<div className="text-[10px] font-bold italic text-slate-300">提交时间</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
|
||||
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||
</div>
|
||||
{(['delivered', 'returned', 'replaced'] as FlowType[]).map((type) => {
|
||||
const count = day[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
data-testid={`asset-flow-cell-${day.date}-${type}`}
|
||||
disabled={count === 0}
|
||||
onClick={() => setSelectedFlow({ date: day.date, type })}
|
||||
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!flowLoading && !flowStats?.daily.length && (
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无交换车数据</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset Summary Table */}
|
||||
<div className="bg-white rounded-sm border border-gray-100 shadow-sm overflow-hidden mb-6">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
|
||||
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
|
||||
<h2 className="text-sm font-bold text-gray-700">资产数据实时汇总</h2>
|
||||
@@ -2647,6 +2878,123 @@ export default function AssetsModule() {
|
||||
)}
|
||||
|
||||
|
||||
{/* Flow Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedFlow && (
|
||||
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<motion.div
|
||||
data-testid="asset-flow-detail-modal"
|
||||
initial={{ opacity: 0, y: 28, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
|
||||
>
|
||||
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">提交时间口径</div>
|
||||
<h3 className="mt-1 text-lg font-black">
|
||||
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}明细
|
||||
</h3>
|
||||
<div className="mt-1 text-[12px] font-bold text-slate-300">
|
||||
共 {selectedFlowDetails.length} 条,包含车牌、流转时间、提交时间、部门、负责人和客户
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFlow(null)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
|
||||
disabled={selectedFlowDetails.length === 0}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Download size={13} />
|
||||
导出当前明细
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
|
||||
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
|
||||
<table className="w-full table-fixed text-left">
|
||||
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
|
||||
<tr>
|
||||
<th className="w-28 px-3 py-3">车牌</th>
|
||||
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}时间</th>
|
||||
<th className="w-40 px-3 py-3">提交时间</th>
|
||||
<th className="w-36 px-3 py-3">部门</th>
|
||||
<th className="w-28 px-3 py-3">业务负责人</th>
|
||||
<th className="px-3 py-3">客户</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
|
||||
{selectedFlowDetails.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-blue-50/40">
|
||||
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
|
||||
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
|
||||
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
|
||||
<td className="px-3 py-3">{item.department || '-'}</td>
|
||||
<td className="px-3 py-3">{item.manager || '-'}</td>
|
||||
<td className="px-3 py-3">{item.customerName || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 lg:hidden">
|
||||
{selectedFlowDetails.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
|
||||
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
|
||||
{item.typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] font-bold text-slate-400">
|
||||
<div>提交</div>
|
||||
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">{item.typeLabel}时间</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">负责人</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">部门</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">客户</div>
|
||||
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedFlowDetails.length === 0 && (
|
||||
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400">当前日期暂无明细</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Vehicle Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{showPlateNumbers && (
|
||||
|
||||
Reference in New Issue
Block a user