feat: add customer operations statistics section
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-03-27 18:27:40 +08:00
parent 17f2222e52
commit f051e3f5aa

View File

@@ -1619,6 +1619,328 @@ export default function App() {
</div> </div>
</section> </section>
{/* Customer Operations Statistics Section */}
<section className="bg-white rounded-sm shadow-sm border border-gray-100 overflow-hidden mb-6">
<div className="p-3 sm:p-4 border-b border-gray-50 flex items-center justify-between bg-emerald-800 text-white relative">
<div className="flex items-center gap-3">
<div className="w-1.5 h-6 bg-emerald-400 rounded-full"></div>
<div>
<h2 className="text-lg font-bold"></h2>
<p className="text-[10px] opacity-70 font-medium">*</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsCustomerFilterOpen(!isCustomerFilterOpen)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
isCustomerFilterOpen || Object.values(customerFilters).some(v => v !== '')
? 'bg-emerald-400 text-emerald-900 shadow-lg shadow-emerald-900/20'
: 'bg-emerald-700/50 text-emerald-100 hover:bg-emerald-700'
}`}
>
<Filter size={14} />
<span></span>
{Object.values(customerFilters).some(v => v !== '') && (
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
)}
</button>
<AnimatePresence>
{isCustomerFilterOpen && (
<>
{/* Backdrop for closing */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsCustomerFilterOpen(false)}
/>
{/* Popover Content */}
<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="absolute top-full right-4 mt-2 w-72 bg-white rounded-xl shadow-2xl border border-gray-100 z-50 p-4 text-gray-800"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-gray-900"></h3>
<button
onClick={() => setCustomerFilters({ customer: '', brand: '', department: '', manager: '', region: '' })}
className="text-[10px] text-emerald-600 hover:text-emerald-700 font-medium"
>
</button>
</div>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider"></label>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" size={12} />
<input
type="text"
placeholder="搜索客户..."
className="w-full bg-gray-50 border border-gray-200 rounded-lg py-2 pl-8 pr-2 text-xs focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 outline-none transition-all"
value={customerFilters.customer}
onChange={(e) => setCustomerFilters(prev => ({ ...prev, customer: e.target.value }))}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider"></label>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" size={12} />
<input
type="text"
placeholder="搜索负责人..."
className="w-full bg-gray-50 border border-gray-200 rounded-lg py-2 pl-8 pr-2 text-xs focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 outline-none transition-all"
value={customerFilters.manager}
onChange={(e) => setCustomerFilters(prev => ({ ...prev, manager: e.target.value }))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider"></label>
<select
className="w-full bg-gray-50 border border-gray-200 rounded-lg py-2 px-2 text-xs focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 outline-none transition-all cursor-pointer"
value={customerFilters.brand}
onChange={(e) => setCustomerFilters(prev => ({ ...prev, brand: e.target.value }))}
>
<option value=""></option>
{uniqueBrands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider"></label>
<select
className="w-full bg-gray-50 border border-gray-200 rounded-lg py-2 px-2 text-xs focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 outline-none transition-all cursor-pointer"
value={customerFilters.department}
onChange={(e) => setCustomerFilters(prev => ({ ...prev, department: e.target.value }))}
>
<option value=""></option>
{uniqueDepts.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider"></label>
<select
className="w-full bg-gray-50 border border-gray-200 rounded-lg py-2 px-2 text-xs focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 outline-none transition-all cursor-pointer"
value={customerFilters.region}
onChange={(e) => setCustomerFilters(prev => ({ ...prev, region: e.target.value }))}
>
<option value=""></option>
{uniqueRegions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
</div>
</div>
<div className="mt-6 pt-4 border-t border-gray-50">
<button
onClick={() => setIsCustomerFilterOpen(false)}
className="w-full bg-emerald-600 text-white py-2 rounded-lg text-xs font-bold hover:bg-emerald-700 transition-colors shadow-lg shadow-emerald-600/20"
>
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
</div>
<div className="p-0 sm:p-2">
{/* Desktop Table View (Customer) */}
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[1000px]">
<thead>
<tr className="bg-emerald-700 text-white text-[11px] uppercase tracking-wider border-b border-emerald-800">
<th className="p-2 font-semibold border-r border-emerald-600 w-40"></th>
<th className="p-2 font-semibold border-r border-emerald-600 w-24"></th>
<th className="p-2 font-semibold border-r border-emerald-600 w-24"></th>
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">4.5T</th>
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">4.5T冷链</th>
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">18T</th>
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">49T</th>
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20"></th>
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20"></th>
<th className="p-2 font-semibold text-center bg-emerald-900/40 w-24"></th>
</tr>
</thead>
<tbody className="text-xs">
{filteredCustomerStats.map((cust) => {
const isExpanded = expandedCustomers.has(cust.customer);
return (
<React.Fragment key={cust.customer}>
<tr
className={`cursor-pointer transition-all border-b border-gray-100 ${
isExpanded ? 'bg-emerald-50/50' : 'hover:bg-gray-50'
}`}
onClick={() => toggleCustomer(cust.customer)}
>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2">
{isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />}
{cust.customer}
</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">
<span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span>
</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{cust.manager}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '4.5T普货' }); }}>{cust.t4_5}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '4.5T冷链' }); }}>{cust.t4_5c}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '18T' }); }}>{cust.t18}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '49T' }); }}>{cust.t49}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, isTrailer: true }); }}>{cust.trailer}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '其他' }); }}>{cust.other}</td>
<td className="p-2 text-center font-bold bg-emerald-50 text-emerald-800 cursor-pointer hover:bg-emerald-100 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer }); }}>{cust.total}</td>
</tr>
{isExpanded && (
<tr className="bg-gray-50/30">
<td colSpan={10} className="p-2">
<div className="grid grid-cols-4 gap-2">
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-gray-700">{cust.customer}</div>
<div className="text-xs text-gray-500 mt-1">: {cust.manager}</div>
</div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-emerald-600">
{cust.t49 > cust.t18 ? '49T 重卡' : (cust.t18 > cust.t4_5c ? '18T 货车' : '4.5T 轻卡')}
</div>
</div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-green-600"></div>
</div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-gray-700">
{((cust.total / (customerData.reduce((s, c) => s + c.total, 0) || 1)) * 100).toFixed(1)}%
</div>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
{/* Mobile Card View (Customer) */}
<div className="lg:hidden p-2 space-y-2">
{filteredCustomerStats.map((cust) => {
const isExpanded = expandedCustomers.has(cust.customer);
return (
<div key={cust.customer} className="bg-white rounded border border-gray-100 shadow-sm overflow-hidden">
<div
className={`p-2 flex justify-between items-center cursor-pointer transition-colors ${
isExpanded ? 'bg-emerald-50' : 'bg-gray-50'
}`}
onClick={() => toggleCustomer(cust.customer)}
>
<div className="flex items-center gap-2">
{isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />}
<div className="flex flex-col">
<span className="font-bold text-gray-800 text-sm">{cust.customer}</span>
<span className="text-[10px] text-emerald-600 font-medium">{cust.region}</span>
</div>
</div>
<div className="text-xs font-bold text-emerald-700 bg-emerald-100 px-2 py-0.5 rounded-full">
: {cust.total}
</div>
</div>
{isExpanded && (
<div className="p-2 space-y-3 bg-white">
{/* Details Cards for Mobile */}
<div className="grid grid-cols-2 gap-2">
<div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-[10px] font-bold text-gray-700">{cust.customer}</div>
<div className="text-[8px] text-gray-500 mt-0.5">: {cust.manager}</div>
</div>
<div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-xs font-bold text-emerald-600">
{cust.t49 > cust.t18 ? '49T 重卡' : (cust.t18 > cust.t4_5c ? '18T 货车' : '4.5T 轻卡')}
</div>
</div>
<div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-xs font-bold text-green-600"></div>
</div>
<div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-xs font-bold text-gray-700">
{((cust.total / (customerData.reduce((s, c) => s + c.total, 0) || 1)) * 100).toFixed(1)}%
</div>
</div>
</div>
<div className="border-t border-gray-50 pt-2">
<div className="text-[8px] text-gray-400 uppercase mb-2"></div>
<div className="grid grid-cols-3 gap-2">
<div
className="text-center bg-gray-50 p-1 rounded cursor-pointer hover:bg-emerald-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '4.5T普货' })}
>
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
<div className="text-[10px] font-bold text-gray-600">{cust.t4_5}</div>
</div>
<div
className="text-center bg-gray-50 p-1 rounded cursor-pointer hover:bg-emerald-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '4.5T冷链' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{cust.t4_5c}</div>
</div>
<div
className="text-center bg-gray-50 p-1 rounded cursor-pointer hover:bg-emerald-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '18T' })}
>
<div className="text-[8px] text-gray-400 uppercase">18T</div>
<div className="text-[10px] font-bold text-gray-600">{cust.t18}</div>
</div>
<div
className="text-center bg-gray-50 p-1 rounded cursor-pointer hover:bg-emerald-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '49T' })}
>
<div className="text-[8px] text-gray-400 uppercase">49T</div>
<div className="text-[10px] font-bold text-gray-600">{cust.t49}</div>
</div>
<div
className="text-center bg-gray-50 p-1 rounded cursor-pointer hover:bg-emerald-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, isTrailer: true })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{cust.trailer}</div>
</div>
<div
className="text-center bg-gray-50 p-1 rounded cursor-pointer hover:bg-emerald-50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, vehicleType: '其他' })}
>
<div className="text-[8px] text-gray-400 uppercase"></div>
<div className="text-[10px] font-bold text-gray-600">{cust.other}</div>
</div>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</section>
{/* Plate Number Modal */} {/* Plate Number Modal */}
<AnimatePresence> <AnimatePresence>
{showPlateNumbers && ( {showPlateNumbers && (