feat: add customer operations statistics section
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
322
src/App.tsx
322
src/App.tsx
@@ -1619,6 +1619,328 @@ export default function App() {
|
||||
</div>
|
||||
</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 */}
|
||||
<AnimatePresence>
|
||||
{showPlateNumbers && (
|
||||
|
||||
Reference in New Issue
Block a user