feat(scheduling): add 近期已干预 summary card (last 7 days)
Restore 替换建议 card and add a new emerald 近期已干预 card. Clicking opens the history modal pre-filtered to the last 7 days (excluding cancelled) via a toggle chip users can switch off. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,12 @@ import Blur from '../../components/Blur';
|
|||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
|
/** When true, pre-filter to the last 7 days (excluding cancelled). */
|
||||||
|
recentOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
type StatusTab = 'all' | NotificationStatus;
|
type StatusTab = 'all' | NotificationStatus;
|
||||||
|
|
||||||
const STATUS_TABS: { key: StatusTab; label: string }[] = [
|
const STATUS_TABS: { key: StatusTab; label: string }[] = [
|
||||||
@@ -36,15 +40,23 @@ function fmtDateTime(iso: string): string {
|
|||||||
return `${y}-${m}-${day} ${hh}:${mm}`;
|
return `${y}-${m}-${day} ${hh}:${mm}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NotificationHistory({ onClose, onChange }: Props) {
|
export default function NotificationHistory({ onClose, onChange, recentOnly = false }: Props) {
|
||||||
const [records, setRecords] = useState<NotificationRecord[]>([]);
|
const [records, setRecords] = useState<NotificationRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [tab, setTab] = useState<StatusTab>('all');
|
const [tab, setTab] = useState<StatusTab>('all');
|
||||||
|
const [recent7d, setRecent7d] = useState(recentOnly);
|
||||||
const [mutatingId, setMutatingId] = useState<number | null>(null);
|
const [mutatingId, setMutatingId] = useState<number | null>(null);
|
||||||
const [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
|
const [executeTarget, setExecuteTarget] = useState<NotificationRecord | null>(null);
|
||||||
const [afterMileageInput, setAfterMileageInput] = useState('');
|
const [afterMileageInput, setAfterMileageInput] = useState('');
|
||||||
const [notesInput, setNotesInput] = useState('');
|
const [notesInput, setNotesInput] = useState('');
|
||||||
|
|
||||||
|
const visibleRecords = recent7d
|
||||||
|
? records.filter(r => {
|
||||||
|
const t = Date.parse(r.createdAt);
|
||||||
|
return Number.isFinite(t) && Date.now() - t <= SEVEN_DAYS_MS && r.status !== 'cancelled';
|
||||||
|
})
|
||||||
|
: records;
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -117,7 +129,7 @@ export default function NotificationHistory({ onClose, onChange }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status tabs */}
|
{/* Status tabs */}
|
||||||
<div className="border-b border-slate-100 px-4 py-2 flex gap-1.5 flex-shrink-0">
|
<div className="border-b border-slate-100 px-4 py-2 flex gap-1.5 flex-shrink-0 flex-wrap items-center">
|
||||||
{STATUS_TABS.map(t => (
|
{STATUS_TABS.map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
@@ -129,6 +141,17 @@ export default function NotificationHistory({ onClose, onChange }: Props) {
|
|||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setRecent7d(v => !v)}
|
||||||
|
className={`text-[11px] px-3 py-1 rounded-full font-medium cursor-pointer transition-colors ${
|
||||||
|
recent7d ? 'bg-emerald-600 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
title="仅看最近 7 天(不含已取消)"
|
||||||
|
>
|
||||||
|
近 7 天
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
@@ -137,14 +160,14 @@ export default function NotificationHistory({ onClose, onChange }: Props) {
|
|||||||
<div className="py-16 text-center text-slate-400 text-xs flex items-center justify-center gap-2">
|
<div className="py-16 text-center text-slate-400 text-xs flex items-center justify-center gap-2">
|
||||||
<Loader2 size={14} className="animate-spin" /> 加载中
|
<Loader2 size={14} className="animate-spin" /> 加载中
|
||||||
</div>
|
</div>
|
||||||
) : records.length === 0 ? (
|
) : visibleRecords.length === 0 ? (
|
||||||
<div className="py-16 text-center text-slate-400">
|
<div className="py-16 text-center text-slate-400">
|
||||||
<Clock className="w-8 h-8 text-slate-200 mx-auto mb-2" />
|
<Clock className="w-8 h-8 text-slate-200 mx-auto mb-2" />
|
||||||
<p className="text-sm">暂无记录</p>
|
<p className="text-sm">{recent7d ? '最近 7 天暂无干预记录' : '暂无记录'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-slate-50">
|
<div className="divide-y divide-slate-50">
|
||||||
{records.map(rec => {
|
{visibleRecords.map(rec => {
|
||||||
const badge = statusBadge(rec.status);
|
const badge = statusBadge(rec.status);
|
||||||
const busy = mutatingId === rec.id;
|
const busy = mutatingId === rec.id;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export default function SchedulingModule() {
|
|||||||
const [batchInFlight, setBatchInFlight] = useState(false);
|
const [batchInFlight, setBatchInFlight] = useState(false);
|
||||||
const [batchResultMsg, setBatchResultMsg] = useState<string | null>(null);
|
const [batchResultMsg, setBatchResultMsg] = useState<string | null>(null);
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [historyRecentOnly, setHistoryRecentOnly] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -277,7 +278,7 @@ export default function SchedulingModule() {
|
|||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
||||||
|
|
||||||
{/* ===== Summary Cards ===== */}
|
{/* ===== Summary Cards ===== */}
|
||||||
<div className="grid grid-cols-3 gap-2.5">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
||||||
{/* 里程高·换下 — warm orange */}
|
{/* 里程高·换下 — warm orange */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
|
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
|
||||||
@@ -330,7 +331,7 @@ export default function SchedulingModule() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
|
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
|
||||||
调度方案
|
替换建议
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
|
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
|
||||||
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
|
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
|
||||||
@@ -340,6 +341,23 @@ export default function SchedulingModule() {
|
|||||||
执行后预计 +{summary?.estimatedGain ?? 0} 台达标
|
执行后预计 +{summary?.estimatedGain ?? 0} 台达标
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 近期已干预 — emerald */}
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
|
||||||
|
className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-bold mb-1 text-emerald-600">
|
||||||
|
近期已干预
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-emerald-700">
|
||||||
|
{loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
|
||||||
|
<span className="text-[10px] font-normal ml-1 text-emerald-400">条</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] mt-0.5 text-emerald-400">
|
||||||
|
最近 7 天 · 点击查看
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== List Card ===== */}
|
{/* ===== List Card ===== */}
|
||||||
@@ -363,7 +381,7 @@ export default function SchedulingModule() {
|
|||||||
<Download size={15} />
|
<Download size={15} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowHistory(true)}
|
onClick={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
|
||||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
||||||
title="调度记录"
|
title="调度记录"
|
||||||
>
|
>
|
||||||
@@ -510,7 +528,11 @@ export default function SchedulingModule() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showHistory && (
|
{showHistory && (
|
||||||
<NotificationHistory onClose={() => setShowHistory(false)} onChange={loadData} />
|
<NotificationHistory
|
||||||
|
onClose={() => setShowHistory(false)}
|
||||||
|
onChange={loadData}
|
||||||
|
recentOnly={historyRecentOnly}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Batch action bar */}
|
{/* Batch action bar */}
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export function generateSuggestions(
|
|||||||
hopelessCount: hopeless.length,
|
hopelessCount: hopeless.length,
|
||||||
suggestionCount: filteredSuggestions.length,
|
suggestionCount: filteredSuggestions.length,
|
||||||
estimatedGain,
|
estimatedGain,
|
||||||
|
recentInterventionCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { suggestions: filteredSuggestions, summary };
|
return { suggestions: filteredSuggestions, summary };
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ function rowToRecord(row: any): NotificationRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count non-cancelled interventions created within the last 7 days.
|
||||||
|
*/
|
||||||
|
export async function fetchRecentInterventionCount(): Promise<number> {
|
||||||
|
const [rows] = (await pool.execute(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications
|
||||||
|
WHERE status != 'cancelled'
|
||||||
|
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`,
|
||||||
|
)) as [any[], unknown];
|
||||||
|
return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
|
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
|
||||||
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
|
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
|||||||
import { mapRegion } from '../vehicles.js';
|
import { mapRegion } from '../vehicles.js';
|
||||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
||||||
import { fetchActiveNotificationMap } from './notify.js';
|
import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js';
|
||||||
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
|
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
|
||||||
@@ -342,6 +342,7 @@ app.get('/', async (c) => {
|
|||||||
// Recalculate summary based on permission-filtered results
|
// Recalculate summary based on permission-filtered results
|
||||||
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
|
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
|
||||||
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
||||||
|
const recentInterventionCount = await fetchRecentInterventionCount();
|
||||||
const filteredSummary: SchedulingSummary = {
|
const filteredSummary: SchedulingSummary = {
|
||||||
qualifiedCount: summary.qualifiedCount,
|
qualifiedCount: summary.qualifiedCount,
|
||||||
hopelessCount: summary.hopelessCount,
|
hopelessCount: summary.hopelessCount,
|
||||||
@@ -349,6 +350,7 @@ app.get('/', async (c) => {
|
|||||||
estimatedGain: masked.filter((s: any) =>
|
estimatedGain: masked.filter((s: any) =>
|
||||||
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
|
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
|
||||||
).length,
|
).length,
|
||||||
|
recentInterventionCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response: SchedulingResponse = {
|
const response: SchedulingResponse = {
|
||||||
@@ -362,7 +364,7 @@ app.get('/', async (c) => {
|
|||||||
console.error('scheduling suggestions error:', e);
|
console.error('scheduling suggestions error:', e);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0 },
|
summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0, recentInterventionCount: 0 },
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
targets: [],
|
targets: [],
|
||||||
} satisfies SchedulingResponse,
|
} satisfies SchedulingResponse,
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export interface SchedulingSummary {
|
|||||||
hopelessCount: number;
|
hopelessCount: number;
|
||||||
suggestionCount: number;
|
suggestionCount: number;
|
||||||
estimatedGain: number;
|
estimatedGain: number;
|
||||||
|
/** Count of interventions created within the last 7 days (excluding cancelled). */
|
||||||
|
recentInterventionCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulingTargetOption {
|
export interface SchedulingTargetOption {
|
||||||
|
|||||||
Reference in New Issue
Block a user