refactor: modularize application domains
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { AssetSummarySectionProps } from './types';
|
||||
|
||||
export function AssetSummaryDesktopTable({
|
||||
processedData,
|
||||
theme,
|
||||
expandedAssetTypes,
|
||||
toggleAssetType,
|
||||
expandedModels,
|
||||
toggleModel,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
||||
<th className="p-3 font-semibold border-r border-gray-100 w-24">车型</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 w-48">品牌型号</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/30 w-24">车辆总资产</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存总数</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-江浙沪</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-广东</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24">本周还车</th>
|
||||
<th className="p-3 font-semibold text-center bg-purple-50/20 w-24">本周替换</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{processedData.map((typeGroup) => (
|
||||
<React.Fragment key={typeGroup.type}>
|
||||
{/* Category Header Row */}
|
||||
<tr className={`border-b border-gray-100 cursor-pointer transition-all ${
|
||||
theme === 'vibrant' ? 'bg-blue-600 text-white hover:bg-blue-700' :
|
||||
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50' :
|
||||
'bg-blue-50/50 hover:bg-blue-50 transition-colors'
|
||||
}`}
|
||||
onClick={() => toggleAssetType(typeGroup.type)}>
|
||||
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedAssetTypes.has(typeGroup.type) ?
|
||||
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
|
||||
}
|
||||
<span>{typeGroup.type}</span>
|
||||
</div>
|
||||
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedAssetTypes.has(typeGroup.type) && typeGroup.models.map((model) => (
|
||||
<React.Fragment key={model.model}>
|
||||
<motion.tr
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
|
||||
onClick={() => toggleModel(model.model)}
|
||||
>
|
||||
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
|
||||
<td className="p-3 border-r border-gray-100 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 text-[9px] font-normal text-gray-400">
|
||||
<span>资产 <span className="font-bold text-gray-600">{model.total}</span></span>
|
||||
<span>库存 <span className="font-bold text-blue-500">{model.inventory}</span></span>
|
||||
<span>运营 <span className="font-bold text-green-500">{model.operating}</span></span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.total}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.inventory}
|
||||
</button>
|
||||
</td>
|
||||
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||
<td key={reg} className="p-3 text-center border-r border-gray-100">
|
||||
{model.inventoryRegions[reg] > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.inventoryRegions[reg]}
|
||||
</button>
|
||||
) : ''}
|
||||
</td>
|
||||
))}
|
||||
<td className="p-3 text-center border-r border-gray-100">
|
||||
{model.pending > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.pending}
|
||||
</button>
|
||||
) : model.pending}
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 text-green-600 font-bold bg-green-50/10">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` });
|
||||
}}
|
||||
className="text-green-600 hover:underline font-bold"
|
||||
>
|
||||
{model.operating}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 text-blue-600 bg-blue-50/5">
|
||||
{model.weeklyDelivered > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.weeklyDelivered}
|
||||
</button>
|
||||
) : model.weeklyDelivered}
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100 text-orange-600 bg-orange-50/5">
|
||||
{model.weeklyReturned > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.weeklyReturned}
|
||||
</button>
|
||||
) : model.weeklyReturned}
|
||||
</td>
|
||||
<td className="p-3 text-center text-purple-600 bg-purple-50/5 font-medium">
|
||||
{model.weeklyReplaced > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` });
|
||||
}}
|
||||
className="text-blue-500 hover:underline font-medium"
|
||||
>
|
||||
{model.weeklyReplaced}
|
||||
</button>
|
||||
) : model.weeklyReplaced}
|
||||
</td>
|
||||
</motion.tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { AssetSummarySectionProps } from './types';
|
||||
|
||||
export function AssetSummaryMobileCards({
|
||||
processedData,
|
||||
theme,
|
||||
expandedAssetTypes,
|
||||
toggleAssetType,
|
||||
expandedModels,
|
||||
toggleModel,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="lg:hidden p-4 space-y-4">
|
||||
{processedData.map((typeGroup) => (
|
||||
<div key={typeGroup.type} className="space-y-3">
|
||||
<div
|
||||
className={`px-3 py-2 rounded flex justify-between items-center shadow-sm cursor-pointer transition-all ${
|
||||
theme === 'vibrant' ? 'bg-blue-600 text-white active:bg-blue-700' :
|
||||
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50' :
|
||||
'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
|
||||
}`}
|
||||
onClick={() => toggleAssetType(typeGroup.type)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedAssetTypes.has(typeGroup.type) ?
|
||||
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-blue-300'} />
|
||||
}
|
||||
<span className="text-xs font-bold">{typeGroup.type}</span>
|
||||
</div>
|
||||
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedAssetTypes.has(typeGroup.type) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="space-y-3 overflow-hidden"
|
||||
>
|
||||
{typeGroup.models.map((model) => (
|
||||
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div
|
||||
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
|
||||
onClick={() => toggleModel(model.model)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className="text-xs font-bold text-gray-700">{model.model}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span>
|
||||
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span>
|
||||
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedModels.has(model.model) && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-gray-50 bg-gray-50/30">
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 mt-2">
|
||||
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` })}>
|
||||
<span className="text-[10px] text-gray-400">总库存</span>
|
||||
<span className="text-xs font-bold text-blue-600">{model.inventory}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}>
|
||||
<span className="text-[10px] text-gray-400">待交车</span>
|
||||
<span className="text-xs font-bold text-gray-600">{model.pending}</span>
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
|
||||
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||
<div key={reg} className="text-center">
|
||||
<div className="text-[8px] text-gray-400 mb-0.5">{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}</div>
|
||||
{model.inventoryRegions[reg] > 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||
}}
|
||||
className="text-[10px] font-bold text-blue-500 hover:underline"
|
||||
>
|
||||
{model.inventoryRegions[reg]}
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-[10px] font-bold text-gray-300">-</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-3 gap-2 pt-1">
|
||||
<div className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}>
|
||||
<span className="text-[8px] text-gray-400 mb-1">本周已交车</span>
|
||||
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
|
||||
</div>
|
||||
<div className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}>
|
||||
<span className="text-[8px] text-gray-400 mb-1">已还车</span>
|
||||
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
|
||||
</div>
|
||||
<div className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}>
|
||||
<span className="text-[8px] text-gray-400 mb-1">已替换</span>
|
||||
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import { AssetSummaryDesktopTable } from './AssetSummaryDesktopTable';
|
||||
import { AssetSummaryMobileCards } from './AssetSummaryMobileCards';
|
||||
import type { AssetSummarySectionProps } from './types';
|
||||
|
||||
export function AssetSummarySection(props: AssetSummarySectionProps) {
|
||||
return (
|
||||
<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>
|
||||
<div className="hidden md:flex items-center gap-1 text-[10px] text-blue-500 bg-blue-50 px-2 py-0.5 rounded">
|
||||
<Info size={10} />
|
||||
点击型号展开详情
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop View Table */}
|
||||
<AssetSummaryDesktopTable {...props} />
|
||||
|
||||
{/* Mobile View Cards for Asset Summary */}
|
||||
<AssetSummaryMobileCards {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowType } from '../../api';
|
||||
import { FLOW_META } from '../FlowDetailModal';
|
||||
import type { FlowStatisticsCardProps } from './types';
|
||||
|
||||
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
|
||||
|
||||
export function FlowStatisticsCard({
|
||||
flowRange,
|
||||
setFlowRange,
|
||||
flowLoading,
|
||||
flowStats,
|
||||
exportFlowDetails,
|
||||
flowDailyExpanded,
|
||||
setFlowDailyExpanded,
|
||||
setSelectedFlow,
|
||||
}: FlowStatisticsCardProps) {
|
||||
return (
|
||||
<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>
|
||||
<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 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<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={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.start}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400">至</div>
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<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={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.end}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</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>
|
||||
{FLOW_TYPES.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>
|
||||
{FLOW_TYPES.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
type InventoryDesktopTableProps = Pick<
|
||||
InventoryStatisticsSectionProps,
|
||||
| 'inventoryTab'
|
||||
| 'inventoryByRegion'
|
||||
| 'expandedInventoryRegions'
|
||||
| 'toggleInventoryRegion'
|
||||
| 'inventoryByModel'
|
||||
| 'expandedInventoryTypes'
|
||||
| 'toggleInventoryType'
|
||||
| 'setVehicleSelection'
|
||||
>;
|
||||
|
||||
export function InventoryDesktopTable({
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryDesktopTableProps) {
|
||||
return (
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
|
||||
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
|
||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
|
||||
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
|
||||
<th className="p-3 font-semibold text-center w-32">库存数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<React.Fragment key={region}>
|
||||
<tr
|
||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span>{region}</span>
|
||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` });
|
||||
}}>
|
||||
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence>
|
||||
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
|
||||
<React.Fragment key={city}>
|
||||
{stats.map((stat, idx) => (
|
||||
<motion.tr
|
||||
key={`${city}-${stat.model}-${idx}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||
>
|
||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
|
||||
</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||
<React.Fragment key={type}>
|
||||
<tr
|
||||
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleInventoryType(type)}
|
||||
>
|
||||
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span>{type}</span>
|
||||
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` });
|
||||
}}>
|
||||
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence>
|
||||
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
|
||||
<React.Fragment key={model}>
|
||||
{stats.map((stat, idx) => (
|
||||
<motion.tr
|
||||
key={`${model}-${stat.region}-${stat.city}-${idx}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||
>
|
||||
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
|
||||
</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||
}}
|
||||
className="text-blue-600 hover:underline font-bold"
|
||||
>
|
||||
{stat.quantity}
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
type InventoryMobileListProps = Pick<
|
||||
InventoryStatisticsSectionProps,
|
||||
| 'inventoryTab'
|
||||
| 'inventoryByRegion'
|
||||
| 'expandedInventoryRegions'
|
||||
| 'toggleInventoryRegion'
|
||||
| 'inventoryByModel'
|
||||
| 'expandedInventoryTypes'
|
||||
| 'toggleInventoryType'
|
||||
| 'setVehicleSelection'
|
||||
>;
|
||||
|
||||
export function InventoryMobileList({
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryMobileListProps) {
|
||||
return (
|
||||
<div className="lg:hidden p-3 space-y-3">
|
||||
{inventoryTab === 'region' ? (
|
||||
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||
<div key={region} className="border border-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleInventoryRegion(region)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span className="text-xs font-bold text-slate-700">{region}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInventoryRegions.has(region) && (
|
||||
<div className="p-2 space-y-2 bg-white">
|
||||
{Object.entries(cities).map(([city, stats]) => (
|
||||
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||
<span className="text-slate-700 font-medium">{stat.model}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||
<div key={type} className="border border-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleInventoryType(type)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<span className="text-xs font-bold text-slate-700">{type}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-blue-600">
|
||||
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInventoryTypes.has(type) && (
|
||||
<div className="p-2 space-y-2 bg-white">
|
||||
{Object.entries(models).map(([model, stats]) => (
|
||||
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
{stat.quantity} 台
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Filter } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { InventoryDesktopTable } from './InventoryDesktopTable';
|
||||
import { InventoryMobileList } from './InventoryMobileList';
|
||||
import type { InventoryStatisticsSectionProps } from './types';
|
||||
|
||||
export function InventoryStatisticsSection({
|
||||
inventoryFilters,
|
||||
setInventoryFilters,
|
||||
draftInventoryFilters,
|
||||
setDraftInventoryFilters,
|
||||
isInventoryFilterOpen,
|
||||
setIsInventoryFilterOpen,
|
||||
uniqueInventoryRegions,
|
||||
uniqueInventoryCities,
|
||||
uniqueInventoryBrands,
|
||||
uniqueInventoryTypes,
|
||||
uniqueInventoryModelsForType,
|
||||
filteredInventoryStats,
|
||||
inventoryTab,
|
||||
setInventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: InventoryStatisticsSectionProps) {
|
||||
const listProps = {
|
||||
inventoryTab,
|
||||
inventoryByRegion,
|
||||
expandedInventoryRegions,
|
||||
toggleInventoryRegion,
|
||||
inventoryByModel,
|
||||
expandedInventoryTypes,
|
||||
toggleInventoryType,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
|
||||
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
||||
<span className="text-[10px] text-gray-400 font-medium">实时更新库存数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 ml-auto pr-2">
|
||||
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
|
||||
<span className="text-2xl font-black text-gray-900 leading-none">
|
||||
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
|
||||
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter size={14} />
|
||||
<span>筛选</span>
|
||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isInventoryFilterOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
|
||||
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-left">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
|
||||
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部区域</option>
|
||||
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">城市</label>
|
||||
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部城市</option>
|
||||
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">品牌</label>
|
||||
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部品牌</option>
|
||||
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">车型</label>
|
||||
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部车型</option>
|
||||
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 block mb-1">批次</label>
|
||||
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||
<option value="">全部批次</option>
|
||||
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors">确认</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
|
||||
<button
|
||||
onClick={() => setInventoryTab('region')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按区域
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInventoryTab('model')}
|
||||
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
按车型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Bar */}
|
||||
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
|
||||
{inventoryFilters.region && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
区域: {inventoryFilters.region}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.city && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
城市: {inventoryFilters.city}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.brand && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
品牌: {inventoryFilters.brand}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.type && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
车型: {inventoryFilters.type}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
{inventoryFilters.model && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||
批次: {inventoryFilters.model}
|
||||
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop View Table */}
|
||||
<InventoryDesktopTable {...listProps} />
|
||||
|
||||
{/* Mobile View */}
|
||||
<InventoryMobileList {...listProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Activity, PlusCircle, Truck, Warehouse } from 'lucide-react';
|
||||
import type { SummaryMetricsProps } from './types';
|
||||
|
||||
export function SummaryMetrics({
|
||||
summary: SUMMARY,
|
||||
operatingRate,
|
||||
inventoryRate,
|
||||
pendingRate,
|
||||
setVehicleSelection: setShowPlateNumbers,
|
||||
}: SummaryMetricsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Header Summary - Ultra Compact */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
|
||||
{/* Total Assets */}
|
||||
<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-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||
<Truck size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">资产总数</div>
|
||||
<div className="text-base font-bold text-gray-800 leading-none">{SUMMARY.totalAssets.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operating */}
|
||||
<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-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<Activity size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总运营</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
|
||||
<span className="text-[8px] text-gray-400 leading-none">自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 && ` 挂${SUMMARY.operating.hanging}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inventory */}
|
||||
<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-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||
<Warehouse size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总库存</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
|
||||
<span className="text-[8px] text-gray-400 leading-none">库{SUMMARY.inventory.inStock} 异{SUMMARY.inventory.abnormal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending */}
|
||||
<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-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||
<PlusCircle size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-blue-500 font-bold uppercase leading-none mb-0.5">待交车</div>
|
||||
<div className="text-base font-bold text-blue-600 leading-none">{SUMMARY.pendingDelivery}</div>
|
||||
</div>
|
||||
</div>
|
||||
</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="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="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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
|
||||
import type {
|
||||
DateRange,
|
||||
InventoryFilters,
|
||||
VehicleModalSelection,
|
||||
} from '../../model';
|
||||
import type { RegionalInventoryStats, SummaryData, TypeSummary } from '../../types';
|
||||
import type { AssetsTheme } from '../AssetsHeader';
|
||||
|
||||
export interface SummaryMetricsProps {
|
||||
summary: SummaryData;
|
||||
operatingRate: number;
|
||||
inventoryRate: number;
|
||||
pendingRate: number;
|
||||
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
}
|
||||
|
||||
export interface FlowStatisticsCardProps {
|
||||
flowRange: DateRange;
|
||||
setFlowRange: Dispatch<SetStateAction<DateRange>>;
|
||||
flowLoading: boolean;
|
||||
flowStats: FlowStatsResponse | null;
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
flowDailyExpanded: boolean;
|
||||
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
|
||||
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||
}
|
||||
|
||||
export interface AssetSummarySectionProps {
|
||||
processedData: TypeSummary[];
|
||||
theme: AssetsTheme;
|
||||
expandedAssetTypes: Set<string>;
|
||||
toggleAssetType: (type: string) => void;
|
||||
expandedModels: Set<string>;
|
||||
toggleModel: (model: string) => void;
|
||||
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
}
|
||||
|
||||
export interface InventoryStatisticsSectionProps {
|
||||
inventoryFilters: InventoryFilters;
|
||||
setInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||
draftInventoryFilters: InventoryFilters;
|
||||
setDraftInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||
isInventoryFilterOpen: boolean;
|
||||
setIsInventoryFilterOpen: Dispatch<SetStateAction<boolean>>;
|
||||
uniqueInventoryRegions: string[];
|
||||
uniqueInventoryCities: string[];
|
||||
uniqueInventoryBrands: string[];
|
||||
uniqueInventoryTypes: string[];
|
||||
uniqueInventoryModelsForType: string[];
|
||||
filteredInventoryStats: RegionalInventoryStats[];
|
||||
inventoryTab: 'region' | 'model';
|
||||
setInventoryTab: Dispatch<SetStateAction<'region' | 'model'>>;
|
||||
inventoryByRegion: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||
expandedInventoryRegions: Set<string>;
|
||||
toggleInventoryRegion: (region: string) => void;
|
||||
inventoryByModel: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||
expandedInventoryTypes: Set<string>;
|
||||
toggleInventoryType: (type: string) => void;
|
||||
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||
}
|
||||
|
||||
export type OverviewViewProps = SummaryMetricsProps
|
||||
& FlowStatisticsCardProps
|
||||
& AssetSummarySectionProps
|
||||
& InventoryStatisticsSectionProps
|
||||
& {
|
||||
allTypesExpanded: boolean;
|
||||
toggleAllAssetTypes: () => void;
|
||||
};
|
||||
Reference in New Issue
Block a user