feat(fuel): Dismiss button on attention rows + optimistic overlay

Click on an AlertCard or AlertListRow to hide the vehicle from
'Attention required' instantly. POST /api/vehicles/{id}/attention-dismiss
stamps the field; optimistic state mirrors that locally so the row
disappears before the network roundtrip. Error path reverts the
overlay and shows window.alert (same pattern as inline-edit).
This commit is contained in:
DEls 2026-08-28 12:45:59 +02:00
parent 70522ed1ae
commit 29524b362d

View file

@ -10,6 +10,7 @@ import {
Banknote,
CheckCircle2,
Droplets,
EyeOff,
Filter,
Fuel,
Gauge,
@ -211,6 +212,14 @@ export function FuelView({
const [page, setPage] = React.useState(1);
const [fleetPage, setFleetPage] = React.useState(1);
// Per-vehicle optimistic dismissals — keyed by vehicleId, value is
// the ISO timestamp the user just clicked. The mapper on the server
// emits attentionDismissedAt, but for an instant hide we merge this
// overlay into the vehicles prop before the flaggedVehicles filter
// runs. router.refresh() pulls the real value from the server.
const [dismissedAt, setDismissedAt] = React.useState<Record<string, string>>({});
const [busyId, setBusyId] = React.useState<string | null>(null);
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter((r) => {
@ -234,17 +243,27 @@ export function FuelView({
});
}, [vehicles, projectFilter, search]);
const vehiclesWithOverlay = React.useMemo(
() =>
vehicles.map((v) =>
dismissedAt[v.id] !== undefined && v.attentionDismissedAt === null
? { ...v, attentionDismissedAt: dismissedAt[v.id] }
: v,
),
[vehicles, dismissedAt],
);
// Attention-required: every flagged vehicle the user hasn't already
// acknowledged. Worst problems first: alert desc → warn desc → spend desc.
const flaggedVehicles = React.useMemo(() => {
return vehicles
return vehiclesWithOverlay
.filter((v) => v.flagged > 0 && !isAlertAcknowledged(v))
.sort((a, b) => {
if (b.alert !== a.alert) return b.alert - a.alert;
if (b.warn !== a.warn) return b.warn - a.warn;
return b.totalSpend - a.totalSpend;
});
}, [vehicles]);
}, [vehiclesWithOverlay]);
React.useEffect(() => {
setPage(1);
@ -308,6 +327,36 @@ export function FuelView({
}
}
async function handleDismiss(vehicleId: string) {
if (busyId !== null) return;
const stamp = new Date().toISOString();
setBusyId(vehicleId);
setDismissedAt((m) => ({ ...m, [vehicleId]: stamp }));
try {
const res = await fetch(
`/api/vehicles/${vehicleId}/attention-dismiss`,
{ method: "POST" },
);
if (!res.ok) {
const data: { error?: string } = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Dismiss failed (${res.status})`);
}
// Re-pull server data so the overlay becomes a no-op and any
// other server-side state stays in sync.
router.refresh();
} catch (err) {
setDismissedAt((m) => {
const next = { ...m };
delete next[vehicleId];
return next;
});
// eslint-disable-next-line no-alert
window.alert(err instanceof Error ? err.message : "Dismiss failed");
} finally {
setBusyId(null);
}
}
return (
<>
<Topbar
@ -486,7 +535,12 @@ export function FuelView({
<ul role="list" className="space-y-3 md:hidden">
{flaggedVehicles.map((v) => (
<li key={v.id}>
<AlertCard v={v} href={vehicleHref(v.id)} />
<AlertCard
v={v}
href={vehicleHref(v.id)}
onDismiss={handleDismiss}
busy={busyId === v.id}
/>
</li>
))}
</ul>
@ -494,7 +548,12 @@ export function FuelView({
<ul role="list" className="hidden space-y-2 md:block">
{flaggedVehicles.map((v) => (
<li key={v.id}>
<AlertListRow v={v} href={vehicleHref(v.id)} />
<AlertListRow
v={v}
href={vehicleHref(v.id)}
onDismiss={handleDismiss}
busy={busyId === v.id}
/>
</li>
))}
</ul>
@ -724,7 +783,17 @@ export function FuelView({
);
}
function AlertCard({ v, href }: { v: VehicleSummary; href: string }) {
function AlertCard({
v,
href,
onDismiss,
busy,
}: {
v: VehicleSummary;
href: string;
onDismiss: (id: string) => void;
busy: boolean;
}) {
const dominant = v.alert > 0 ? "alert" : "warn";
const toneClasses =
dominant === "alert"
@ -794,12 +863,36 @@ function AlertCard({ v, href }: { v: VehicleSummary; href: string }) {
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDismiss(v.id);
}}
disabled={busy}
aria-label={`Mark ${v.description} as seen`}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border/60 bg-card px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-[#F2B165]/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#F2B165] disabled:opacity-50"
>
<EyeOff className="h-3.5 w-3.5" />
{busy ? "Dismissing…" : "Dismiss"}
</button>
</div>
</Link>
);
}
function AlertListRow({ v, href }: { v: VehicleSummary; href: string }) {
function AlertListRow({
v,
href,
onDismiss,
busy,
}: {
v: VehicleSummary;
href: string;
onDismiss: (id: string) => void;
busy: boolean;
}) {
const dominant = v.alert > 0 ? "alert" : "warn";
const stripeColor =
dominant === "alert" ? "bg-red-500" : "bg-[#F2B165]";
@ -851,6 +944,22 @@ function AlertListRow({ v, href }: { v: VehicleSummary; href: string }) {
<span className="ml-auto hidden w-24 shrink-0 text-right text-sm font-semibold tabular-nums text-foreground sm:inline-block">
{v.totalSpend > 0 ? formatCurrency(v.totalSpend) : "—"}
</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDismiss(v.id);
}}
disabled={busy}
aria-label={`Mark ${v.description} as seen`}
className="ml-2 inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border/60 bg-card px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-[#F2B165]/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#F2B165] disabled:opacity-50"
>
<EyeOff className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{busy ? "Dismissing…" : "Dismiss"}
</span>
</button>
<ArrowRight
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden