Files
parking_solution/apps/web/src/ui/DeviceFooter.tsx
T
julian cce99aadfd
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s
fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
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
2026-06-28 15:15:09 +02:00

198 lines
7.8 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
import { qk } from "../lib/query.js";
import { useLiveStore } from "../lib/live-store.js";
// Fixed device-status footer for the booth chrome. One compact chip per configured
// device — relays, readers, cameras, printers — labelled by ROLE, never vendor
// (e.g. "Lexuesi hyrje", "Printer kabina", "Kamera dalje"), with a traffic-light
// dot. Fault detail does NOT pollute the footer: clicking opens a small panel that
// lists the degraded/offline devices and their issues. Status is fed by the
// DeviceMonitor over the WS (snapshot on connect + per-device pushes, held in the
// live store); a REST snapshot seeds it / fills in if the WS is briefly down.
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
const DOT: Record<DeviceStatus["state"], string> = {
ready: "bg-term-green",
degraded: "bg-term-amber",
offline: "bg-term-red",
};
const TEXT: Record<DeviceStatus["state"], string> = {
ready: "text-term-text",
degraded: "text-term-amber",
offline: "text-term-red",
};
/** i18n key for a device category. */
const CATEGORY_KEY: Record<DeviceStatus["category"], string> = {
access: "devices.catAccess",
reader: "devices.catReader",
camera: "devices.catCamera",
printer: "devices.catPrinter",
vision: "devices.catVision",
};
/** i18n key for the role/direction token (null = no suffix). */
function roleKey(roleKind: DeviceStatus["roleKind"]): string | null {
return roleKind ? `devices.role.${roleKind}` : null;
}
/** Stable display order: access (barrier) first, then readers, cameras, printers. */
const ORDER: Record<DeviceStatus["category"], number> = {
access: 0,
reader: 1,
camera: 2,
printer: 3,
vision: 4,
};
/** "Lexuesi hyrje" — category word + localised role/direction (when known). */
function useLabel() {
const { t } = useTranslation();
return (d: DeviceStatus) => {
const cat = t(CATEGORY_KEY[d.category]);
const rk = roleKey(d.roleKind);
return rk ? `${cat} ${t(rk)}` : cat;
};
}
function sortDevices(list: DeviceStatus[]): DeviceStatus[] {
return [...list].sort(
(a, b) => ORDER[a.category] - ORDER[b.category] || (a.roleKind ?? "").localeCompare(b.roleKind ?? ""),
);
}
export function DeviceFooter() {
const { t } = useTranslation();
const label = useLabel();
// Seed/fallback from REST; the WS keeps the live store authoritative thereafter.
const seed = useQuery({ queryKey: qk.deviceStatus, queryFn: fetchDeviceStatus });
const live = useLiveStore((s) => s.devices);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLElement>(null);
// Close the issues panel on an outside click or Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Prefer the live store (WS); fall back to the REST snapshot before the first push.
const fromLive = Object.values(live);
const devices = sortDevices(fromLive.length > 0 ? fromLive : seed.data?.devices ?? []);
const problems = devices.filter((d) => d.state !== "ready");
return (
<footer
ref={rootRef}
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[0.6875rem]"
>
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
{t("devices.footerTitle")}
</span>
<div className="flex items-center gap-1.5 overflow-x-auto">
{devices.length === 0 ? (
<span className="text-term-muted">{t("devices.none")}</span>
) : (
devices.map((d) => {
const isProblem = d.state !== "ready";
return (
<button
key={d.deviceId}
type="button"
// Only a problem chip is interactive (opens the issues panel).
onClick={isProblem ? () => setOpen((v) => !v) : undefined}
aria-disabled={!isProblem}
title={isProblem ? t("devices.clickForIssues") : undefined}
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-term border border-term-border bg-term-panel-2 px-2 py-0.5 ${
isProblem ? "cursor-pointer hover:border-term-amber" : "cursor-default"
}`}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]} ${
d.state === "offline" ? "animate-pulse" : ""
}`}
/>
<span className={TEXT[d.state]}>{label(d)}</span>
</button>
);
})
)}
</div>
{/* Right-aligned roll-up; clicking opens the issues panel when any exist. */}
<button
type="button"
disabled={problems.length === 0}
onClick={() => setOpen((v) => !v)}
className="ml-auto shrink-0 tabular-nums disabled:cursor-default"
>
{problems.length === 0 ? (
devices.length > 0 ? (
<span className="text-term-green">{t("devices.allOk")}</span>
) : null
) : (
<span className="text-term-amber hover:underline">
{t("devices.issuesCount", { count: problems.length })}
</span>
)}
</button>
{/* Issues panel — anchored above the footer, lists only problem devices. */}
{open && problems.length > 0 && (
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{t("devices.issuesTitle")}
</span>
<button
type="button"
onClick={() => setOpen(false)}
className="text-term-muted hover:text-term-text"
aria-label={t("common.close")}
>
✕
</button>
</div>
<ul className="max-h-[40vh] overflow-y-auto p-1.5">
{problems.map((d) => (
<li
key={d.deviceId}
className="flex items-start gap-2 border-b border-term-border/40 px-1.5 py-1.5 last:border-b-0"
>
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className={`text-[0.75rem] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
<span className="shrink-0 text-[0.625rem] uppercase tracking-wider text-term-muted">
{t(`devices.state.${d.state}`)}
</span>
</div>
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
</div>
</div>
</li>
))}
</ul>
</div>
)}
</footer>
);
}