feat: 客户多选筛选、统计报表里程与监控看板数据一致
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 资产管理按客户筛选改为多选(支持同时选多个客户) - 新增 MultiSearchSelect 组件(搜索+标签+复选框) - 统计报表 todayTotal 改用监控缓存数据,与里程看板一致 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
88
src/components/MultiSearchSelect.tsx
Normal file
88
src/components/MultiSearchSelect.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
export function MultiSearchSelect({ value, onChange, options, placeholder, className }: {
|
||||||
|
value: string[];
|
||||||
|
onChange: (v: string[]) => void;
|
||||||
|
options: string[];
|
||||||
|
placeholder: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query) return options;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return options.filter((o) => o.toLowerCase().includes(q));
|
||||||
|
}, [options, query]);
|
||||||
|
|
||||||
|
const toggle = (name: string) => {
|
||||||
|
onChange(value.includes(name) ? value.filter(c => c !== name) : [...value, name]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||||
|
{value.map(c => (
|
||||||
|
<span key={c} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded text-[10px] font-medium">
|
||||||
|
{c}
|
||||||
|
<button onClick={() => toggle(c)} className="hover:text-red-500 ml-0.5 leading-none">×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`flex items-center bg-white border border-gray-200 rounded-lg shadow-sm cursor-pointer transition-all focus-within:ring-2 focus-within:ring-emerald-500/20 focus-within:border-emerald-500 ${className || 'text-xs py-1.5 px-2'}`}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 outline-none bg-transparent min-w-0 text-inherit"
|
||||||
|
placeholder={value.length > 0 ? `已选 ${value.length} 个客户` : placeholder}
|
||||||
|
value={open ? query : ''}
|
||||||
|
onChange={(e) => { setQuery(e.target.value); if (!open) setOpen(true); }}
|
||||||
|
onFocus={() => { setOpen(true); setQuery(''); }}
|
||||||
|
/>
|
||||||
|
<ChevronDown size={14} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-auto">
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="px-2 py-1.5 text-xs text-gray-400 cursor-pointer hover:bg-gray-50 border-b border-gray-100"
|
||||||
|
onClick={() => onChange([])}
|
||||||
|
>
|
||||||
|
清除全部已选
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map(o => (
|
||||||
|
<div
|
||||||
|
key={o}
|
||||||
|
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-emerald-50 transition-colors flex items-center gap-1.5 ${value.includes(o) ? 'bg-emerald-50 text-emerald-700 font-medium' : 'text-gray-700'}`}
|
||||||
|
onClick={() => toggle(o)}
|
||||||
|
>
|
||||||
|
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${value.includes(o) ? 'bg-emerald-500 border-emerald-500 text-white' : 'border-gray-300'}`}>
|
||||||
|
{value.includes(o) && <span className="text-[9px]">✓</span>}
|
||||||
|
</span>
|
||||||
|
{o}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="px-2 py-3 text-xs text-gray-400 text-center">无匹配项</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup,
|
|||||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
|
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
|
||||||
import type { WeeklyDetailItem } from './api';
|
import type { WeeklyDetailItem } from './api';
|
||||||
import { SearchSelect } from '../../components/SearchSelect';
|
import { SearchSelect } from '../../components/SearchSelect';
|
||||||
|
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||||
|
|
||||||
|
|
||||||
// --- Constants ---
|
// --- Constants ---
|
||||||
@@ -105,9 +106,9 @@ export default function AssetsModule() {
|
|||||||
|
|
||||||
// Customer section state
|
// Customer section state
|
||||||
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set());
|
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set());
|
||||||
const [customerFilters, setCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' });
|
const [customerFilters, setCustomerFilters] = useState({ customer: [] as string[], brand: '', department: '', manager: '', region: '' });
|
||||||
const [isCustomerFilterOpen, setIsCustomerFilterOpen] = useState(false);
|
const [isCustomerFilterOpen, setIsCustomerFilterOpen] = useState(false);
|
||||||
const [draftCustomerFilters, setDraftCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' });
|
const [draftCustomerFilters, setDraftCustomerFilters] = useState({ customer: [] as string[], brand: '', department: '', manager: '', region: '' });
|
||||||
|
|
||||||
// Inventory statistics section state
|
// Inventory statistics section state
|
||||||
const [inventoryData, setInventoryData] = useState<RegionalInventoryStats[]>([]);
|
const [inventoryData, setInventoryData] = useState<RegionalInventoryStats[]>([]);
|
||||||
@@ -371,7 +372,7 @@ export default function AssetsModule() {
|
|||||||
|
|
||||||
// Derived data for customer section
|
// Derived data for customer section
|
||||||
const filteredCustomerStats = useMemo(() => customerData.filter((s) => {
|
const filteredCustomerStats = useMemo(() => customerData.filter((s) => {
|
||||||
const mc = !customerFilters.customer || s.customer === customerFilters.customer;
|
const mc = customerFilters.customer.length === 0 || customerFilters.customer.includes(s.customer);
|
||||||
const mb = !customerFilters.brand || s.brand === customerFilters.brand;
|
const mb = !customerFilters.brand || s.brand === customerFilters.brand;
|
||||||
const md = !customerFilters.department || s.department === customerFilters.department;
|
const md = !customerFilters.department || s.department === customerFilters.department;
|
||||||
const mm = !customerFilters.manager || s.manager === customerFilters.manager;
|
const mm = !customerFilters.manager || s.manager === customerFilters.manager;
|
||||||
@@ -2148,14 +2149,14 @@ export default function AssetsModule() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => { if (!isCustomerFilterOpen) setDraftCustomerFilters({...customerFilters}); setIsCustomerFilterOpen(!isCustomerFilterOpen); }}
|
onClick={() => { if (!isCustomerFilterOpen) setDraftCustomerFilters({...customerFilters}); setIsCustomerFilterOpen(!isCustomerFilterOpen); }}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
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 !== '')
|
isCustomerFilterOpen || Object.values(customerFilters).some(v => Array.isArray(v) ? v.length > 0 : v !== '')
|
||||||
? 'bg-emerald-400 text-emerald-900 shadow-lg shadow-emerald-900/20'
|
? 'bg-emerald-400 text-emerald-900 shadow-lg shadow-emerald-900/20'
|
||||||
: 'bg-emerald-700/50 text-emerald-100 hover:bg-emerald-700'
|
: 'bg-emerald-700/50 text-emerald-100 hover:bg-emerald-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Filter size={14} />
|
<Filter size={14} />
|
||||||
<span>筛选</span>
|
<span>筛选</span>
|
||||||
{Object.values(customerFilters).some(v => v !== '') && (
|
{Object.values(customerFilters).some(v => Array.isArray(v) ? v.length > 0 : v !== '') && (
|
||||||
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
|
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -2176,14 +2177,14 @@ export default function AssetsModule() {
|
|||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-bold text-gray-900">数据筛选</h3>
|
<h3 className="text-sm font-bold text-gray-900">数据筛选</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => setDraftCustomerFilters({ customer: '', brand: '', department: '', manager: '', region: '' })} className="text-[10px] text-emerald-600 hover:text-emerald-700 font-medium">重置所有</button>
|
<button onClick={() => setDraftCustomerFilters({ customer: [], brand: '', department: '', manager: '', region: '' })} className="text-[10px] text-emerald-600 hover:text-emerald-700 font-medium">重置所有</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">客户名称</label>
|
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">客户名称</label>
|
||||||
<SearchSelect value={draftCustomerFilters.customer} onChange={(v) => setDraftCustomerFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" />
|
<MultiSearchSelect value={draftCustomerFilters.customer} onChange={(v) => setDraftCustomerFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -2233,12 +2234,12 @@ export default function AssetsModule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Object.values(customerFilters).some(v => v !== '') && (
|
{Object.values(customerFilters).some(v => Array.isArray(v) ? v.length > 0 : v !== '') && (
|
||||||
<div className="px-3 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
|
<div className="px-3 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
|
||||||
{customerFilters.customer && (
|
{customerFilters.customer.length > 0 && (
|
||||||
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[10px] flex items-center gap-1">
|
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[10px] flex items-center gap-1">
|
||||||
客户: {customerFilters.customer}
|
客户: {customerFilters.customer.join(', ')}
|
||||||
<button onClick={() => setCustomerFilters(prev => ({...prev, customer: ''}))} className="hover:text-red-500 ml-0.5">×</button>
|
<button onClick={() => setCustomerFilters(prev => ({...prev, customer: []}))} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{customerFilters.manager && (
|
{customerFilters.manager && (
|
||||||
@@ -2265,7 +2266,7 @@ export default function AssetsModule() {
|
|||||||
<button onClick={() => setCustomerFilters(prev => ({...prev, region: ''}))} className="hover:text-red-500 ml-0.5">×</button>
|
<button onClick={() => setCustomerFilters(prev => ({...prev, region: ''}))} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setCustomerFilters({ customer: '', brand: '', department: '', manager: '', region: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
|
<button onClick={() => setCustomerFilters({ customer: [], brand: '', department: '', manager: '', region: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -380,6 +380,23 @@ app.get('/targets', async (c) => {
|
|||||||
periodsMap.set(p.target_id, list);
|
periodsMap.set(p.target_id, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用监控缓存的里程数据(与里程看板一致)
|
||||||
|
const cacheVehicleMap = new Map<string, number>();
|
||||||
|
if (monitoringCache) {
|
||||||
|
for (const v of monitoringCache.vehicles) {
|
||||||
|
cacheVehicleMap.set(v.plate, Math.max(0, v.dailyKm || 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [targetVehicleRows] = await pool.execute(
|
||||||
|
`SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0`
|
||||||
|
) as any;
|
||||||
|
const targetIdPlatesMap = new Map<number, string[]>();
|
||||||
|
for (const r of targetVehicleRows) {
|
||||||
|
const list = targetIdPlatesMap.get(r.target_id) || [];
|
||||||
|
list.push(r.plate_number);
|
||||||
|
targetIdPlatesMap.set(r.target_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const result = targets.map((t: any) => {
|
const result = targets.map((t: any) => {
|
||||||
const s = statsMap.get(t.id) || {};
|
const s = statsMap.get(t.id) || {};
|
||||||
@@ -409,7 +426,7 @@ app.get('/targets', async (c) => {
|
|||||||
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
||||||
assessmentYears: t.assessment_years,
|
assessmentYears: t.assessment_years,
|
||||||
periods,
|
periods,
|
||||||
todayTotal: Number(s.today_total) || 0,
|
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum: number, plate: string) => sum + (cacheVehicleMap.get(plate) || 0), 0),
|
||||||
cumulativeTotal: Number(s.cumulative_total) || 0,
|
cumulativeTotal: Number(s.cumulative_total) || 0,
|
||||||
avgCompletion: (Number(s.avg_completion) || 0) * 100,
|
avgCompletion: (Number(s.avg_completion) || 0) * 100,
|
||||||
qualifiedCount: Number(s.qualified_count) || 0,
|
qualifiedCount: Number(s.qualified_count) || 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user