cce99aadfd
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)
Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
(h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
font utility to rem across the web app (~230 sites in 25 files + the
.label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
layout stays put, so chrome never clips; tall content scrolls its own container.
Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.
Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
options already in the Type filter.
Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
name; overstay keeps a row tint). Removed the now-redundant status filter; only
the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).
Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
opening + cash-taken = expected reads clearly. Money values no longer line-wrap.
Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
following row by one column — it now emits a full label+value pair.
Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
120 lines
5.1 KiB
TypeScript
120 lines
5.1 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
|
|
|
|
/** Keep one entry per (plate, direction) — newest wins (the list is newest-first). */
|
|
function dedupePlates(plates: PlateRead[]): PlateRead[] {
|
|
const seen = new Set<string>();
|
|
const out: PlateRead[] = [];
|
|
for (const p of plates) {
|
|
const key = `${p.plate}|${p.direction}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Entry/exit evidence images for a session. Lets the operator verify the car at the
|
|
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
|
|
// served with a long immutable cache); clicking one enlarges it. Read-only.
|
|
|
|
export function SnapshotStrip({ identity }: { identity: string }) {
|
|
const { t } = useTranslation();
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["snapshots", identity],
|
|
queryFn: () => fetchSnapshots(identity),
|
|
enabled: !!identity,
|
|
});
|
|
const [zoom, setZoom] = useState<string | null>(null);
|
|
|
|
const shots = data?.snapshots ?? [];
|
|
const failures = data?.failures ?? [];
|
|
const plates = data?.plates ?? [];
|
|
|
|
/** Localized direction label for a snapshot/failure tile. */
|
|
const dirLabel = (dir: "entry" | "exit" | null): string =>
|
|
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
|
|
|
|
if (isLoading) return <div className="text-[0.6875rem] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
|
if (shots.length === 0 && failures.length === 0 && plates.length === 0)
|
|
return <div className="text-[0.6875rem] text-term-muted">{t("pay.noSnapshots")}</div>;
|
|
|
|
return (
|
|
<>
|
|
{/* Recognized plate(s) (ANPR) — advisory. Dedup by plate+direction so an
|
|
entry+exit read of the same plate shows once per direction. */}
|
|
{plates.length > 0 && (
|
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
|
{dedupePlates(plates).map((p, i) => (
|
|
<span
|
|
key={`${p.plate}-${p.direction}-${i}`}
|
|
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
|
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
|
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
|
}`}
|
|
>
|
|
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
|
<span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
|
|
{typeof p.confidence === "number" && (
|
|
<span className="text-[0.625rem] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{shots.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
onClick={() => setZoom(s.id)}
|
|
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
|
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
|
|
>
|
|
<img
|
|
src={snapshotImageUrl(s.id)}
|
|
alt={s.direction ?? "snapshot"}
|
|
className="h-20 w-28 object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<span
|
|
className={`text-[0.5625rem] uppercase tracking-wider ${
|
|
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
|
}`}
|
|
>
|
|
{dirLabel(s.direction)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
|
|
{/* Failed captures — a placeholder tile so an absent image is explained, not
|
|
silently missing. Shown only when no successful shot exists for the same
|
|
direction (the server already filters recovered captures out). */}
|
|
{failures.map((f, i) => (
|
|
<div
|
|
key={`fail-${f.direction ?? "both"}-${i}`}
|
|
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
|
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
|
>
|
|
<span className="text-lg leading-none text-term-amber">⚠</span>
|
|
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
|
<span className="px-1 text-[0.5625rem] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{zoom && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
|
|
onClick={() => setZoom(null)}
|
|
>
|
|
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|