fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s

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
This commit is contained in:
2026-06-28 15:15:09 +02:00
parent f706726eeb
commit cce99aadfd
31 changed files with 410 additions and 401 deletions
+40 -40
View File
@@ -206,8 +206,8 @@ function CategorySection({
{warnings.length > 0 && (
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
<strong className="text-[0.75rem] text-term-amber">{t("setup.warnTitle")}</strong>
<ul className="mt-1 list-disc pl-5 text-[0.75rem] text-term-amber">
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
@@ -233,7 +233,7 @@ function CategorySection({
)}
{blockedNoController ? (
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
<p className="m-0 text-[0.75rem] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
) : (
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
@@ -301,7 +301,7 @@ function AssignmentRow({
}
return (
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[0.75rem]">
<strong className="text-term-text">{assignment.driverId}</strong>
{host && <span className="tabular-nums text-term-muted">{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} />
@@ -690,19 +690,19 @@ function DeviceForm({
{selected && (
<div className="mt-3">
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
<p className="mb-2 text-[0.75rem] text-term-muted">{selected.description}</p>
{canDiscover && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
{scanning ? t("setup.scanning") : t("setup.scan")}
</button>
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
{scanError && <span className="ml-2 text-[0.75rem] text-term-red">{scanError}</span>}
{found && found.length === 0 && <p className="mt-2 text-[0.75rem] text-term-muted">{t("setup.noControllersFound")}</p>}
{found && found.length > 0 && (
<ul className="mt-2 list-none p-0">
{found.map((d) => (
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
<li key={d.id} className="my-1 flex items-center gap-2 text-[0.75rem]">
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
{t("setup.use")}
</button>
@@ -734,7 +734,7 @@ function DeviceForm({
f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not
// the string "true"). The label sits beside the box, with the help below.
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<input
type="checkbox"
className="mt-0.5"
@@ -859,7 +859,7 @@ function DeviceForm({
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
{isCamera && (
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<input
type="checkbox"
className="mt-0.5"
@@ -877,7 +877,7 @@ function DeviceForm({
(evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where
the exit cam's back-plate read would phantom-exit a car that just entered. */}
{isCamera && anpr && (
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<input
type="checkbox"
className="mt-0.5"
@@ -901,7 +901,7 @@ function DeviceForm({
a Test connection first. We surface each field separately, matching the
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
{isCamera && Boolean(config.alarmPushEnabled) && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
{!editing?.id ? (
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
@@ -964,9 +964,9 @@ function DeviceForm({
)}
</div>
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
{testError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
{tested && (
<div className="mt-2 text-[12px]">
<div className="mt-2 text-[0.75rem]">
<div className="text-term-text">
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
@@ -993,10 +993,10 @@ function DeviceForm({
</button>
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
{anprError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
{anprResult &&
(anprResult.ok ? (
<div className="mt-2 text-[12px] text-term-green">
<div className="mt-2 text-[0.75rem] text-term-green">
{t("setup.anprOk", {
plate: anprResult.plate,
confidence: Math.round(anprResult.confidence * 100),
@@ -1007,7 +1007,7 @@ function DeviceForm({
)}
</div>
) : (
<div className="mt-2 text-[12px] text-term-amber">
<div className="mt-2 text-[0.75rem] text-term-amber">
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
{anprResult.tookMs != null && (
@@ -1027,14 +1027,14 @@ function DeviceForm({
</button>
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
{printError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
{printError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
{printResult &&
(printResult.ok ? (
<div className="mt-2 text-[12px] text-term-green">
<div className="mt-2 text-[0.75rem] text-term-green">
{t("setup.printOk", { ms: printResult.tookMs })}
</div>
) : (
<div className="mt-2 text-[12px] text-term-amber">
<div className="mt-2 text-[0.75rem] text-term-amber">
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
</div>
@@ -1060,12 +1060,12 @@ function DeviceForm({
</select>
</div>
{!backendIps.some((c) => c.onDeviceSubnet) && (
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
<span className="text-[0.75rem] text-term-amber">{t("setup.noNicOnSubnet")}</span>
)}
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
</div>
)}
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
{saveError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
</div>
)}
</div>
@@ -1105,11 +1105,11 @@ function OutputEditor({
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
<p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
<label className="my-1 inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.pulseOpenHint")}>
<label className="my-1 inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.pulseOpenHint")}>
{t("setup.pulseOpenMs")}
<input
type="number"
@@ -1125,7 +1125,7 @@ function OutputEditor({
barriers pulse (their button/presence terminals are in the Inputs section). */}
{relays.map((r, i) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.relay")}
<input
type="number"
@@ -1150,7 +1150,7 @@ function OutputEditor({
{/* Alert relay: which input fires the blink + the blink cadence. */}
{r.direction === "radarAlert" && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.triggerInputHint")}>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.triggerInputHint")}>
{t("setup.triggerInput")}
<input
type="number"
@@ -1161,7 +1161,7 @@ function OutputEditor({
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.lockLaneHint")}>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.lockLaneHint")}>
{t("setup.lockLane")}
<select
className="select input-sm w-auto"
@@ -1172,7 +1172,7 @@ function OutputEditor({
<option value="exit">{t("setup.lockLaneExit")}</option>
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.blinkOnMs")}
<input
type="number"
@@ -1183,7 +1183,7 @@ function OutputEditor({
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.blinkOffMs")}
<input
type="number"
@@ -1251,11 +1251,11 @@ function InputEditor({
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
<label className="my-1 inline-flex items-start gap-2 text-[12px] text-term-muted">
<label className="my-1 inline-flex items-start gap-2 text-[0.75rem] text-term-muted">
<input
type="checkbox"
className="mt-0.5"
@@ -1274,7 +1274,7 @@ function InputEditor({
const isButton = row.role === "button";
return (
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.inputTerminal")}
<input
type="number"
@@ -1298,7 +1298,7 @@ function InputEditor({
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
{row.role !== "alertTrigger" && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.inputServesRelay")}
<select
className="select input-sm w-auto"
@@ -1319,7 +1319,7 @@ function InputEditor({
{/* Presence: active-low (a radar wired opposite the button). */}
{isPresence && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.activeLowHint")}>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.activeLowHint")}>
<input
type="checkbox"
checked={!!row.activeLow}
@@ -1331,7 +1331,7 @@ function InputEditor({
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
{isButton && !hasPresenceFor(row.relay) && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
<input
type="number"
@@ -1381,9 +1381,9 @@ function BindingPicker({
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.controller")}
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
<option value="" disabled>
@@ -1400,7 +1400,7 @@ function BindingPicker({
})}
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.relay")}
<select
className="select input-sm w-auto"
@@ -1424,7 +1424,7 @@ function BindingPicker({
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
</div>
{controller && relays.length === 0 && (
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
<p className="mt-1.5 text-[0.75rem] text-term-amber">{t("setup.noRelaysConfigured")}</p>
)}
</div>
);
@@ -1441,7 +1441,7 @@ function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: s
? "border-term-red text-term-red"
: "border-term-muted text-term-muted";
return (
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
<span className={`rounded-term border px-1.5 text-[0.625rem] font-semibold uppercase tracking-wider ${cls}`}>
{label ?? direction}
</span>
);