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:
parent
70522ed1ae
commit
29524b362d
1 changed files with 115 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue