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
+2 -1
View File
@@ -548,8 +548,9 @@ export class ShiftService {
"",
"-- Arkëtime sipas burimit --",
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
"",
"-- Arka --",
+84 -93
View File
@@ -26,26 +26,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
// reconciles via the pay/exit modal — never a free barrier open.
// See wiki/concepts/booth-exit-flow.md.
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
type KindFilter = "transient" | "subscription";
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
if (s.subscription) return "subscription";
if (s.overstay) return "overstay";
if (!s.open && s.withinGrace) return "exiting";
if (s.paidAt) return "paid";
return "unpaid";
}
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
if (s.overstay)
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
}
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -70,9 +52,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
});
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
// Filters: free-text search, status, and transient-vs-subscriber.
// Filters: free-text search + transient-vs-subscriber. (No status filter — the status
// column was dropped; an unpaid transient is normal and a subscriber is marked ★.)
const [search, setSearch] = useState("");
const [status, setStatus] = useState<StatusFilter | "">("");
const [kind, setKind] = useState<KindFilter | "">("");
const sessions = useMemo(() => data?.sessions ?? [], [data]);
@@ -81,7 +63,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
return sessions.filter((s) => {
if (kind === "transient" && s.subscription) return false;
if (kind === "subscription" && !s.subscription) return false;
if (status && statusOf(s) !== status) return false;
if (q) {
// Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits.
const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase();
@@ -89,14 +70,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
}
return true;
});
}, [sessions, search, status, kind]);
}, [sessions, search, kind]);
const statusOpts: SegOption<StatusFilter>[] = [
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
{ value: "paid", label: t("booth.fStatusPaid") },
{ value: "exiting", label: t("booth.fStatusExiting") },
{ value: "overstay", label: t("booth.fStatusOverstay") },
];
const kindOpts: SegOption<KindFilter>[] = [
{ value: "transient", label: t("booth.fKindTransient") },
{ value: "subscription", label: t("booth.fKindSubscription") },
@@ -120,7 +95,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
<Panel
title={t("booth.activeSessions")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
{filtered.length}
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
</span>
@@ -129,7 +104,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
>
<div className="flex h-full flex-col">
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
</FilterBar>
@@ -143,70 +117,87 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
: t("booth.noMatch")}
</div>
) : (
filtered.map((s) => {
const badge = statusBadge(s);
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
return (
<div
key={s.identity}
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
>
<button
type="button"
onClick={() => onPick(s.identity)}
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
title={t("booth.openPayExit")}
>
<span className="text-term-text">
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
</span>
{s.plate && (
<span
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")}
>
{s.plate}
</span>
)}
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
<span
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
title={badge.titleKey ? t(badge.titleKey) : undefined}
// A real table — aligned columns (who · plate · entry · elapsed · action). No
// status column: an unpaid transient is the normal case, and a subscriber is
// already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row
// tint so that fraud-relevant signal isn't lost. The whole row is clickable
// (→ pay/exit modal); the trailing cell holds the audited Open-barrier action.
<table className="w-full text-[0.75rem] tabular-nums">
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colWho")}</th>
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
<th className="px-2 py-1.5" />
</tr>
</thead>
<tbody>
{filtered.map((s) => {
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
// Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid
// but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and
// NOT a subscription (assist-open lives in the modal). An unpaid transient
// gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard.
const canReopen = s.paidAt && !s.overstay && !s.subscription;
return (
<tr
key={s.identity}
onClick={() => onPick(s.identity)}
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
s.overstay ? "bg-term-red/5" : ""
}`}
title={t("booth.openPayExit")}
>
{t(badge.key)}
</span>
</button>
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
re-pulse for a car that paid but the barrier didn't confirm. NOT an
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
live in the pay/exit modal — the list must not offer a one-click open,
which would bypass an unpaid window charge). An unpaid transient has no
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
{s.paidAt && !s.overstay && !s.subscription ? (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
onClick={() => handleReopen(s)}
className="btn btn-pay btn-sm shrink-0"
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
>
{t("booth.openBarrier")}
</button>
) : (
<span className="w-[88px] shrink-0" />
)}
{msg && (
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</span>
)}
</div>
);
})
<td className="px-2 py-1.5 text-term-text">
{s.subscription ? (
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
) : (
s.identity
)}
</td>
<td className="px-2 py-1.5">
{s.plate && (
<span
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")}
>
{s.plate}
</span>
)}
</td>
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
{formatRelativeDateTime(s.enteredAt, t)}
</td>
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
{formatDuration(s.enteredAt, new Date().toISOString())}
</td>
<td className="px-2 py-1.5 text-right">
{canReopen && (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
onClick={(e) => {
e.stopPropagation(); // don't also open the pay/exit modal
void handleReopen(s);
}}
className="btn btn-pay btn-sm"
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
>
{t("booth.openBarrier")}
</button>
)}
{msg && (
<span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
+15 -15
View File
@@ -226,7 +226,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
aria-describedby={undefined}
>
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{isSubscription
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
: `${t("pay.ticket")} ${identity}`}
@@ -244,19 +244,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
{blockedByOther ? (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateOtherTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">
<div className="mt-1 text-[0.75rem] text-term-text">
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
</div>
</>
) : (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
<div className="mt-1 text-[0.75rem] text-term-text">{t("shift.gateBody")}</div>
<button
type="button"
onClick={handleOpenShift}
@@ -321,7 +321,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
</span>
<span className="text-3xl font-bold text-term-cyan">
@@ -341,22 +341,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
gate; once paid, prompt the operator to open the barrier; a prepaid
subscriber sees the assist explanation only after revealing it. */}
{subWindowDue && !windowPaid ? (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.windowChargeHint")}
</div>
) : isSubscription && windowPaid ? (
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[12px] text-term-text">
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.windowPaidHint")}
</div>
) : isSubscription && assistRevealed ? (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.subAssistHint")}
</div>
) : null}
{/* For an overstay, explain why a top-up is required (no free exit). */}
{isOverstay && (
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.overstayHint")}
</div>
)}
@@ -368,7 +368,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
subscriber window charge that's still unpaid). */}
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
<div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((tn) => (
<button
key={tn}
@@ -384,7 +384,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
{phase !== "done" && !isSubscription && (
<label className="flex items-center gap-2 text-[12px]">
<label className="flex items-center gap-2 text-[0.75rem]">
<input
type="checkbox"
className="accent-term-amber"
@@ -401,10 +401,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
signed `void` event — the entry is never edited. */}
{voiding && phase !== "done" && (
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
<div className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{t("pay.cancelTicketTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
<div className="mt-1 text-[0.75rem] text-term-text">{t("pay.cancelTicketHint")}</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
<button
@@ -560,7 +560,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
return (
<div className="flex items-baseline justify-between">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
<span className={`text-sm ${valueClass}`}>{value}</span>
</div>
);
+8 -14
View File
@@ -49,13 +49,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
<div className="flex items-end gap-4">
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
<div className="pb-1 text-term-muted">
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
<div className="text-[0.6875rem] uppercase tracking-wider">{t("booth.inside")}</div>
<div className="text-sm tabular-nums">
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
</div>
</div>
<div className="ml-auto text-right">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
{occ.free == null ? "∞" : occ.free}
</div>
@@ -67,7 +67,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
</div>
)}
{occ.full && (
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[0.6875rem] font-bold uppercase tracking-widest text-term-red">
{t("booth.lotFull")}
</div>
)}
@@ -106,7 +106,7 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
className="input h-11 flex-1 px-3 text-lg tabular-nums"
/>
<button type="submit" className="btn btn-primary btn-lg">
{t("booth.open")}
{t("booth.openTicket")}
</button>
</form>
);
@@ -134,7 +134,7 @@ function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; ra
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
</svg>
<div className="leading-tight">
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
</div>
</div>
@@ -181,10 +181,10 @@ export function BoothScreen() {
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
// Live-feed filters: free-text search, event category, and direction/source.
// Live-feed filters: free-text search, event type, and source. (No direction filter —
// HYRJE/DALJE there just duplicated the entry/exit options already in the Type filter.)
const [feedSearch, setFeedSearch] = useState("");
const [feedType, setFeedType] = useState<FeedCat | "">("");
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
// Live overlays from the WS store.
@@ -212,7 +212,6 @@ export function BoothScreen() {
const fq = feedSearch.trim().toLowerCase();
const events = scoped.filter((e) => {
if (feedType && feedCat(e.type) !== feedType) return false;
if (feedDir && e.direction !== feedDir) return false;
if (feedSrc) {
const isBooth = e.source === "manual";
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
@@ -231,10 +230,6 @@ export function BoothScreen() {
{ value: "void", label: t("booth.fEvtVoid") },
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
];
const feedDirOpts: SegOption<"entry" | "exit">[] = [
{ value: "entry", label: t("booth.fDirEntry") },
{ value: "exit", label: t("booth.fDirExit") },
];
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
{ value: "booth", label: t("booth.fSrcBooth") },
{ value: "reader", label: t("booth.fSrcReader") },
@@ -272,7 +267,7 @@ export function BoothScreen() {
<Panel
title={t("booth.liveFeed")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
{events.length}
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
</span>
@@ -288,7 +283,6 @@ export function BoothScreen() {
onChange={setFeedType}
allLabel={t("booth.filterAll")}
/>
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
</FilterBar>
)}
+1 -1
View File
@@ -46,7 +46,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
autoComplete="current-password"
/>
</div>
{error && <p className="mb-3 text-[12px] text-term-red">{error}</p>}
{error && <p className="mb-3 text-[0.75rem] text-term-red">{error}</p>}
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
{busy ? t("auth.signingIn") : t("auth.signIn")}
</button>
+7 -7
View File
@@ -31,7 +31,7 @@ function LogRow({ log }: { log: AppLogRecord }) {
<button
type="button"
onClick={() => hasDetail && setOpen((v) => !v)}
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[12px] ${
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[0.75rem] ${
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
}`}
>
@@ -44,17 +44,17 @@ function LogRow({ log }: { log: AppLogRecord }) {
{open && hasDetail && (
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
{log.path && (
<div className="mb-1 text-[11px] text-term-muted">
<div className="mb-1 text-[0.6875rem] text-term-muted">
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
</div>
)}
{log.context && Object.keys(log.context).length > 0 && (
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-text">
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-text">
{JSON.stringify(log.context, null, 2)}
</pre>
)}
{log.stack && (
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-red/90">
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-red/90">
{log.stack}
</pre>
)}
@@ -136,12 +136,12 @@ export function LogsViewer() {
<div className="card p-2">
{q.isLoading ? (
<div className="p-3 text-[12px] text-term-muted">{t("common.loading")}</div>
<div className="p-3 text-[0.75rem] text-term-muted">{t("common.loading")}</div>
) : logs.length === 0 ? (
<div className="p-3 text-[12px] text-term-muted">{t("logs.empty")}</div>
<div className="p-3 text-[0.75rem] text-term-muted">{t("logs.empty")}</div>
) : (
<>
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[10px] uppercase tracking-wider text-term-muted">
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[0.625rem] uppercase tracking-wider text-term-muted">
<span>{t("logs.time")}</span>
<span>{t("logs.level")}</span>
<span>{t("logs.source")}</span>
+8 -8
View File
@@ -82,7 +82,7 @@ export function Profile({
<h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.accountSection")}
</h2>
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
<div className="grid grid-cols-2 gap-3 text-[0.6875rem] text-term-muted">
<div>
<span className="block">{t("profile.username")}</span>
<span className="text-sm text-term-text">{user.username}</span>
@@ -92,7 +92,7 @@ export function Profile({
<span className="text-sm text-term-text">{user.roleName}</span>
</div>
</div>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.fullName")}
<input
className="input"
@@ -101,7 +101,7 @@ export function Profile({
onChange={(e) => setFullName(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.email")}
<input
className="input"
@@ -115,7 +115,7 @@ export function Profile({
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
{t("profile.saveProfile")}
</button>
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
{accountMsg && <span className="text-[0.6875rem] text-term-muted">{accountMsg}</span>}
</div>
</section>
@@ -124,7 +124,7 @@ export function Profile({
<h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.passwordSection")}
</h2>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.currentPassword")}
<input
className="input"
@@ -134,7 +134,7 @@ export function Profile({
onChange={(e) => setCurrent(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.newPassword")}
<input
className="input"
@@ -144,7 +144,7 @@ export function Profile({
onChange={(e) => setNext(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
{t("profile.confirmPassword")}
<input
className="input"
@@ -163,7 +163,7 @@ export function Profile({
>
{t("profile.changePassword")}
</button>
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
{pwMsg && <span className="text-[0.6875rem] text-term-muted">{pwMsg}</span>}
</div>
</section>
</div>
+7 -7
View File
@@ -76,13 +76,13 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{t("recycleBin.title")}
</h1>
{retentionDays > 0 && (
<span className="text-[12px] text-term-muted">
<span className="text-[0.75rem] text-term-muted">
{t("recycleBin.retentionNote", { days: retentionDays })}
</span>
)}
</div>
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
{error && <p className="mb-2 text-[0.75rem] text-term-red">{error}</p>}
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
{!binQ.isLoading && items.length === 0 ? (
@@ -90,9 +90,9 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{t("recycleBin.empty")}
</p>
) : (
<table className="w-full text-[13px]">
<table className="w-full text-[0.8125rem]">
<thead>
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
<tr className="border-b border-term-border text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
@@ -103,7 +103,7 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{items.map((it) => (
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
<td className="py-1.5 pr-3">
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[0.6875rem] text-term-muted">
{t(KIND_KEY[it.kind])}
</span>
</td>
@@ -146,10 +146,10 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{purging && (
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
<p className="text-[13px] text-term-text">
<p className="text-[0.8125rem] text-term-text">
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
</p>
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
<p className="mt-1 text-[0.75rem] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
{t("common.cancel")}
+6 -6
View File
@@ -95,7 +95,7 @@ export function Reports() {
</button>
))}
</div>
<div className="ml-2 flex items-center gap-1 text-[12px] text-term-muted">
<div className="ml-2 flex items-center gap-1 text-[0.75rem] text-term-muted">
<span>{t("reports.groupBy")}</span>
<select
className="select input-sm w-auto"
@@ -247,7 +247,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
{/* Cash / card + duration + subscription breakdown (numbers). */}
<Panel title={t("reports.chart.breakdown")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]">
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[0.8125rem]">
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
@@ -262,7 +262,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</Panel>
</div>
<p className="text-[11px] text-term-muted">
<p className="text-[0.6875rem] text-term-muted">
{t("reports.footnote", { tz: data.tz })}
</p>
</div>
@@ -290,7 +290,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
: "text-term-text";
return (
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{label}</div>
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</div>
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
</div>
);
@@ -299,7 +299,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-term border border-term-border bg-term-panel p-3">
<h2 className="mb-2 text-[11px] uppercase tracking-wider text-term-muted">{title}</h2>
<h2 className="mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</h2>
{children}
</div>
);
@@ -315,5 +315,5 @@ function Row({ label, value }: { label: string; value: string }) {
}
function Empty({ t }: { t: TFunction }) {
return <p className="py-12 text-center text-[12px] text-term-muted">{t("reports.noData")}</p>;
return <p className="py-12 text-center text-[0.75rem] text-term-muted">{t("reports.noData")}</p>;
}
+6 -6
View File
@@ -63,7 +63,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
)}
</div>
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
<Modal
open={editing != null}
@@ -93,13 +93,13 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-term-text">{r.name}</span>
<span className="text-[0.8125rem] font-semibold text-term-text">{r.name}</span>
{r.builtin && (
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-term-amber">
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
{t("roles.builtin")}
</span>
)}
<span className="text-[11px] text-term-muted">
<span className="text-[0.6875rem] text-term-muted">
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span>
</div>
@@ -155,11 +155,11 @@ function RoleEditor({
<div className="mt-1 grid grid-cols-1 gap-1">
{Object.entries(grouped).map(([resource, list]) => (
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
<span className="w-28 shrink-0 text-[12px] font-semibold text-term-text">{resource}</span>
<span className="w-28 shrink-0 text-[0.75rem] font-semibold text-term-text">{resource}</span>
{list.map((p) => {
const action = p.split(":")[1]!;
return (
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
<label key={p} className="flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
{action}
</label>
+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>
);
+33 -26
View File
@@ -175,7 +175,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
</div>
{q.isError && (
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{t("shifts.loadFailed")}</div>
)}
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
@@ -183,7 +183,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
{!q.isLoading && list.length === 0 && (
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
)}
{list.map((s) => (
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
@@ -202,7 +202,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
onChanged={refreshAll}
/>
) : (
<p className="px-3 py-6 text-center text-[12px] text-term-muted">{t("shifts.selectAShift")}</p>
<p className="px-3 py-6 text-center text-[0.75rem] text-term-muted">{t("shifts.selectAShift")}</p>
)}
</div>
</div>
@@ -228,7 +228,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
}
return (
<span className="flex items-center gap-2">
{err && <span className="text-[12px] text-term-red">{err}</span>}
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
{busy ? t("shift.starting") : t("shift.startShift")}
</button>
@@ -244,11 +244,11 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
<button
type="button"
onClick={onClick}
className={`card w-full p-2.5 text-left text-[12px] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
className={`card w-full p-2.5 text-left text-[0.75rem] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 font-semibold text-term-text">
{open && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showOperator ? s.operator : when(s.startedAt)}
</span>
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
@@ -300,9 +300,9 @@ function ShiftActivityLog({
return (
<div className="flex h-full min-h-0 flex-col">
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
<span className="flex items-center gap-2 font-semibold text-term-text">
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showOperator && `${shift.operator} · `}
{formatRelativeDateTime(shift.startedAt, t)}
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
@@ -316,10 +316,10 @@ function ShiftActivityLog({
</span>
)}
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[0.6875rem] tabular-nums sm:grid-cols-4">
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<Figure label={t("shifts.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
@@ -331,8 +331,8 @@ function ShiftActivityLog({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-1">
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
{q.isLoading && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("common.loading")}</p>}
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.noActivity")}</p>}
{events.map((e) => (
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
))}
@@ -372,14 +372,15 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
{report ? (
// Result — the signed Z-report.
<div className="text-[13px] tabular-nums">
<div className="text-[0.8125rem] tabular-nums">
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
<span />
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubSales")} value={money(report.subscriptionSalesMinor, report.currency)} sub />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span />
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
@@ -399,20 +400,24 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
</div>
) : (
// Confirm — show the live takings (split by source) + drawer before closing.
<div className="text-[13px] tabular-nums">
<div className="text-[0.8125rem] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<Figure label={t("shift.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span />
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<span />
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
</div>
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
{err && <p className="mt-2 text-[0.75rem] text-term-red">{err}</p>}
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button>
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
@@ -452,7 +457,7 @@ function VoucherModal({ currency, onClose, onDone }: { currency: string | null;
return (
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
<div className="flex flex-col gap-2 text-[13px]">
<div className="flex flex-col gap-2 text-[0.8125rem]">
<div className="flex flex-wrap items-center gap-2">
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
@@ -461,8 +466,8 @@ function VoucherModal({ currency, onClose, onDone }: { currency: string | null;
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
</div>
<div className="text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
{msg && <div className="text-[12px] text-term-muted">{msg}</div>}
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
<div className="mt-1 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
@@ -480,16 +485,17 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
return (
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
{!x ? (
<p className="text-[12px] text-term-muted">{t("common.loading")}</p>
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
) : (
<div className="text-[13px] tabular-nums">
<div className="text-[0.8125rem] tabular-nums">
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubSales")} value={money(x.subscriptionSalesMinor, x.currency)} sub />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span />
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
@@ -500,7 +506,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
</div>
<div className="mt-2 text-[11px] text-term-muted">{t("shift.xReportHint")}</div>
<div className="mt-2 text-[0.6875rem] text-term-muted">{t("shift.xReportHint")}</div>
<div className="mt-3 flex justify-end">
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
</div>
@@ -513,8 +519,9 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span>
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
<span className={`whitespace-nowrap ${sub ? "text-term-muted/70" : "text-term-muted"}`}>{label}</span>
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
</div>
);
}
+6 -6
View File
@@ -69,7 +69,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
return (
<section className="card mt-6 max-w-md p-4">
<div className="flex flex-wrap items-center gap-1.5 text-[13px]">
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
{occ == null ? (
<span className="text-term-muted">…</span>
@@ -93,7 +93,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="label">{t("site.capacityLabel")}</span>
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
</div>
<label className="flex items-center gap-2 text-[12px] text-term-text">
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
@@ -103,7 +103,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
{t("site.printExitDefault")}
<span className="hint">{t("site.printExitHint")}</span>
</label>
<label className="flex items-start gap-2 text-[12px] text-term-text">
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="mt-0.5 accent-term-amber"
@@ -115,7 +115,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.reserveSubsHint")}</span>
</span>
</label>
<label className="flex items-start gap-2 text-[12px] text-term-text">
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="mt-0.5 accent-term-amber"
@@ -127,7 +127,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.anprEntryHint")}</span>
</span>
</label>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")}
</div>
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
@@ -153,7 +153,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
))}
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
</div>
</div>
)}
+24 -16
View File
@@ -412,7 +412,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
<ul className="mb-3 list-none p-0">
{subs.map((s) => (
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[0.75rem]">
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
@@ -464,13 +464,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
</option>
))}
</select>
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
{plans.length === 0 && <span className="text-[0.75rem] text-term-amber">{t("subs.planNoneAvail")}</span>}
</span>
</>
) : (
<>
<label className="label">{t("subs.plan")}</label>
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
<span className="text-[0.8125rem] text-term-text">{form.planId || t("subs.noPrice")}</span>
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
move the sub to a different VERSION of that same plan (e.g. one with
different timeframes). Price stays as billed. Only shown when the sub has
@@ -486,7 +486,15 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
if (cur) versions.unshift(cur);
}
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
return <span className="text-[12px] text-term-muted">{t("subs.versionOnlyOne")}</span>;
// Keep the 2-col grid flow intact: a lone cell here would shift every
// following row by one column (label↔input swap). Emit a full row —
// the version label + the "only one version" hint as its control.
return (
<>
<label className="label">{t("subs.version")}</label>
<span className="text-[0.75rem] text-term-muted">{t("subs.versionOnlyOne")}</span>
</>
);
}
return (
<>
@@ -504,7 +512,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
</option>
))}
</select>
<span className="text-[12px] text-term-muted">{t("subs.versionHint")}</span>
<span className="text-[0.75rem] text-term-muted">{t("subs.versionHint")}</span>
</span>
</>
);
@@ -523,7 +531,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
<span className="text-[0.75rem] text-term-muted">{t("subs.quantityHint")}</span>
</span>
</>
)}
@@ -533,7 +541,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
<>
<label className="label">{t("subs.tender")}</label>
<span className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
<input
type="radio"
name="tender"
@@ -543,7 +551,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
/>
{t("subs.tenderCash")}
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
<input
type="radio"
name="tender"
@@ -553,13 +561,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
/>
{t("subs.tenderCard")}
</label>
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span>
<span className="text-[0.75rem] text-term-muted">{t("subs.tenderHint")}</span>
</span>
</>
)}
<label className="label">{t("subs.carLimit")}</label>
<span className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label>
{form.carBound && (
@@ -580,7 +588,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">
<span className="text-[0.75rem] text-term-muted">
× {t(PERIOD_KEY[selectedPlan.period])}
</span>
</span>
@@ -592,7 +600,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
override it — this is exactly what will be charged + signed. */}
{editing === "new" && form.planId.trim() !== "" && (
<span className="text-[12px] text-term-cyan">
<span className="text-[0.75rem] text-term-cyan">
{quoting
? t("subs.quoting")
: quote
@@ -610,7 +618,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
<h4 className="mt-4 mb-1 text-[0.75rem] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
{form.credentials.map((c, i) => (
<div key={i} className="mb-1.5 flex items-center gap-2">
{/* Operator chooses the credential type: QR (auto-generated) or RFID
@@ -625,7 +633,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
c.value.trim() ? (
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
) : (
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
<span className="flex-1 self-center text-[0.75rem] italic text-term-muted">{t("subs.qrAutoGen")}</span>
)
) : (
// RFID: the value is read off a physical card (or typed). "Read card"
@@ -643,7 +651,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
{/* Capture panel: pick a reader, present the card; the captured value fills
the credential. The OTHER reader keeps serving the live flow. */}
{capture && (
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[0.75rem]">
{capture.phase === "pick" ? (
<>
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
@@ -673,7 +681,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div>
</Modal>
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[0.75rem] text-term-green" : "mt-3 text-[0.75rem] text-term-red"}>{msg.text}</p>}
</section>
);
}
+18 -18
View File
@@ -237,19 +237,19 @@ export function SubscriptionPlansManager() {
return (
<section className="px-4 py-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
<h3 className="text-[0.8125rem] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
{t("plans.add")}
</button>
</div>
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
<p className="mb-3 text-[0.75rem] text-term-muted">{t("plans.intro")}</p>
{msg && (
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
<div className={`mb-3 text-[0.75rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
)}
{groups.length === 0 ? (
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
<p className="text-[0.8125rem] text-term-muted">{t("plans.noneYet")}</p>
) : (
<div className="flex flex-col gap-2">
{groups.map(({ planId, head: p, active, versions }) => {
@@ -263,15 +263,15 @@ export function SubscriptionPlansManager() {
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-semibold text-term-text">{p.name}</span>
{active ? (
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
<span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("plans.inForce")}</span>
) : (
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
<span className="rounded border border-term-border px-1 text-[0.625rem] text-term-muted">{t("plans.retired")}</span>
)}
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
{versions > 1 && <span className="text-[0.625rem] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
</div>
{/* Details: price · hours · effective */}
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted">
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[0.75rem] text-term-muted">
<span className="tabular-nums text-term-text">
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</span>
@@ -280,7 +280,7 @@ export function SubscriptionPlansManager() {
</div>
{/* Used by */}
<div className="mt-1 text-[12px]">
<div className="mt-1 text-[0.75rem]">
{users.length > 0 ? (
<button
type="button"
@@ -295,12 +295,12 @@ export function SubscriptionPlansManager() {
)}
</div>
{isOpen && users.length > 0 && (
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]">
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[0.75rem]">
{users.map((s) => (
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
{s.holderName || t("subs.unnamed")}
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
{s.status !== "active" && <span className="ml-1 text-[0.625rem]">({t(STATUS_KEY[s.status])})</span>}
</li>
))}
</ul>
@@ -349,14 +349,14 @@ export function SubscriptionPlansManager() {
<span className="flex items-center gap-2">
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
</span>
</div>
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
window they're charged the transient tariff for the gap. Off = 24/7. */}
<div className="mt-3 border-t border-term-border pt-3">
<label className="flex items-center gap-2 text-[12px] text-term-text">
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
@@ -370,7 +370,7 @@ export function SubscriptionPlansManager() {
<label className="label">{t("plans.days")}</label>
<span className="flex flex-wrap gap-2">
{DOW_ORDER.map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
@@ -386,7 +386,7 @@ export function SubscriptionPlansManager() {
))}
</span>
<label className="label">{t("plans.window")}</label>
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
<span className="flex flex-wrap items-center gap-2 text-[0.75rem] text-term-muted">
{t("plans.enterAfter")}
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
{t("plans.exitBefore")}
@@ -395,14 +395,14 @@ export function SubscriptionPlansManager() {
<label className="label">{t("plans.grace")}</label>
<span className="flex items-center gap-2">
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
<span className="text-[0.75rem] text-term-muted">{t("plans.graceHint")}</span>
</span>
</div>
)}
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
<p className="mt-1.5 text-[0.6875rem] text-term-muted">{t("plans.timeframesHint")}</p>
</div>
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
{form.planId && <p className="mt-2 text-[0.6875rem] text-term-amber">{t("plans.newVersionHint")}</p>}
<div className="mt-4 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
+9 -9
View File
@@ -354,11 +354,11 @@ export function TariffComposer() {
<section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
{!state?.active ? (
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
{t("tariff.noRateCard")}
</p>
) : (
<p className="mb-4 text-[12px] text-term-muted">
<p className="mb-4 text-[0.75rem] text-term-muted">
{t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length,
@@ -407,7 +407,7 @@ export function TariffComposer() {
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
{form.base.mode === "stepped" && form.tiers.length > 0 && (
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[12px] text-term-red">
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
{t("tariff.steppedTiersConflict")}
</p>
)}
@@ -432,7 +432,7 @@ export function TariffComposer() {
<label className="label">{t("tariff.tierDays")}</label>
<span className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)}
</label>
@@ -444,7 +444,7 @@ export function TariffComposer() {
<span className="text-term-muted">–</span>
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
)}
</span>
<label className="label">{t("tariff.tierDates")}</label>
@@ -478,7 +478,7 @@ export function TariffComposer() {
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button>
{msg && (
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
)}
</div>
</section>
@@ -504,7 +504,7 @@ function PricingEditor(props: {
const { t, pricing: p } = props;
return (
<div>
<div className="mb-3 flex gap-4 text-[12px]">
<div className="mb-3 flex gap-4 text-[0.75rem]">
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")}
@@ -538,7 +538,7 @@ function PricingEditor(props: {
<td className="px-2 py-1">
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
</td>
<td className="px-2 py-1">
@@ -587,7 +587,7 @@ function PricingEditor(props: {
) : (
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
)}
</td>
+7 -7
View File
@@ -131,7 +131,7 @@ export function TariffLab() {
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
{t("lab.load")}
</button>
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
</div>
{/* Hypothetical session inputs */}
@@ -167,7 +167,7 @@ export function TariffLab() {
<label className="label">{t("lab.payment")}</label>
<span className="flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1 text-[12px] text-term-text">
<label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
{t("lab.paid")}
</label>
@@ -190,7 +190,7 @@ export function TariffLab() {
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")}
</button>
{err && <span className="text-[12px] text-term-red">{err}</span>}
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</div>
{result && (
@@ -198,19 +198,19 @@ export function TariffLab() {
{/* Outcome */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px]">
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[10px] uppercase text-term-red">
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[10px] uppercase text-term-green">
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
@@ -230,7 +230,7 @@ export function TariffLab() {
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[12px] tabular-nums">
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40">
+4 -4
View File
@@ -51,7 +51,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
)}
</div>
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
<UserForm
@@ -100,8 +100,8 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
</Modal>
<div className="overflow-hidden rounded-term border border-term-border">
<table className="w-full text-[12px]">
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
<table className="w-full text-[0.75rem]">
<thead className="bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="px-3 py-1.5 text-left">{t("users.username")}</th>
<th className="px-3 py-1.5 text-left">{t("users.role")}</th>
@@ -291,7 +291,7 @@ function UserForm({
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
{/* Optional profile metadata. */}
<div className="mt-4 mb-2 text-[11px] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
<div className="mt-4 mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
<div className="grid grid-cols-2 gap-3">
<div className="field">
<span className="label">{t("users.fullName")}</span>
+8 -8
View File
@@ -180,7 +180,7 @@ body {
background: var(--color-term-bg);
color: var(--color-term-text);
font-family: var(--font-mono);
font-size: 13px;
font-size: 0.8125rem;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
/* Crisp text and no rubber-banding on the fixed appliance display. */
@@ -266,23 +266,23 @@ body {
/* Small / dense variant for inline table cells */
.input-sm {
height: var(--control-h-sm);
@apply px-2 text-[12px];
@apply px-2 text-[0.75rem];
}
.field {
@apply flex flex-col gap-1;
}
.label {
@apply text-[11px] uppercase tracking-wider text-term-muted;
@apply text-[0.6875rem] uppercase tracking-wider text-term-muted;
}
.hint {
@apply text-[11px] leading-snug text-term-muted;
@apply text-[0.6875rem] leading-snug text-term-muted;
}
/* ---- Buttons: a button must look pressable, never like a field ---- */
.btn {
@apply inline-flex items-center justify-center gap-1.5 rounded-term border
px-3 text-[12px] font-semibold uppercase tracking-wider
px-3 text-[0.75rem] font-semibold uppercase tracking-wider
transition-colors select-none;
height: var(--control-h-md);
/* Neutral default: a filled grey body, not a bare outline. */
@@ -302,11 +302,11 @@ body {
}
.btn-sm {
height: var(--control-h-sm);
@apply px-2.5 text-[11px];
@apply px-2.5 text-[0.6875rem];
}
.btn-lg {
height: var(--control-h-lg);
@apply px-5 text-[13px];
@apply px-5 text-[0.8125rem];
}
/* Primary: FILLED amber, dark text — the unmistakable main action. */
@@ -366,7 +366,7 @@ body {
}
.card-head {
@apply flex items-center justify-between border-b border-term-border
bg-term-panel-2 px-4 py-2 text-[12px] uppercase tracking-wider text-term-muted;
bg-term-panel-2 px-4 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted;
}
.card-body {
@apply p-4;
+8 -11
View File
@@ -121,7 +121,7 @@ export const en: Catalog = {
scanPlaceholder: "Scan or type ticket number…",
laneEntry: "Entry",
laneExit: "Exit",
open: "Open",
openTicket: "Read",
occupancy: "Occupancy",
occUnavailable: "occupancy unavailable",
inside: "inside",
@@ -136,6 +136,11 @@ export const en: Catalog = {
insideCount: "inside",
noActiveSessions: "No active sessions.",
noMatch: "No sessions match the filter.",
// Active-sessions table column headers.
colWho: "Ticket / subscriber",
colPlate: "Plate",
colEntry: "Entry",
colElapsed: "Elapsed",
badgeOverstay: "overstay",
badgeOverstayTitle:
"Paid session. The customer failed to exit during the grace period. A new period began.",
@@ -144,14 +149,8 @@ export const en: Catalog = {
filterSearchSessions: "Search ticket / subscriber / plate…",
filterSearchFeed: "Search event / identity / plate…",
filterAll: "All",
fStatusUnpaid: "Unpaid",
fStatusPaid: "Paid",
fStatusExiting: "Exiting",
fStatusOverstay: "Overstay",
fKindTransient: "Transient",
fKindSubscription: "Subscribers",
fDirEntry: "Entry",
fDirExit: "Exit",
fSrcBooth: "Booth",
fSrcReader: "Reader",
fEvtEntry: "Entry",
@@ -702,10 +701,9 @@ export const en: Catalog = {
card: "Card:",
srcTickets: "Tickets:",
srcSubscriptions: "Subscriptions:",
srcSubSales: "sales",
srcSubWindow: "out-of-window",
drawerSection: "— Drawer —",
openingFloat: "Opening float:",
openingFloat: "Opening cash:",
cashTaken: "Cash taken:",
cashAdded: "Cash added:",
cashRemoved: "Cash removed:",
@@ -739,7 +737,6 @@ export const en: Catalog = {
card: "Card",
srcTickets: "Tickets",
srcSubscriptions: "Subscriptions",
srcSubSales: "subs sales",
srcSubWindow: "out-of-window",
expectedDrawer: "Expected drawer",
filterFrom: "From",
@@ -757,7 +754,7 @@ export const en: Catalog = {
noActivity: "No activity in this shift.",
current: "current",
drawerSection: "Drawer",
openingFloat: "Opening float",
openingFloat: "Opening cash",
cashTaken: "Cash taken",
cashAdded: "Cash added",
cashRemoved: "Cash removed",
+9 -12
View File
@@ -123,7 +123,7 @@ export const sq = {
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
laneEntry: "Hyrje",
laneExit: "Dalje",
open: "Hap",
openTicket: "Lexo",
occupancy: "Prania",
occUnavailable: "zënia e padisponueshme",
inside: "brenda",
@@ -138,6 +138,11 @@ export const sq = {
insideCount: "brenda",
noActiveSessions: "Asnjë sesion aktiv.",
noMatch: "Asnjë rezultat për filtrin.",
// Kokat e kolonave të tabelës së sesioneve aktive.
colWho: "Biletë / abonent",
colPlate: "Targa",
colEntry: "Hyrja",
colElapsed: "Koha brenda",
badgeOverstay: "tej afatit",
badgeOverstayTitle:
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
@@ -146,14 +151,8 @@ export const sq = {
filterSearchSessions: "Kërko biletë / abonent / targë…",
filterSearchFeed: "Kërko event / identitet / targë…",
filterAll: "Të gjitha",
fStatusUnpaid: "Papaguar",
fStatusPaid: "Paguar",
fStatusExiting: "Duke dalë",
fStatusOverstay: "Tej afatit",
fKindTransient: "Kalimtarë",
fKindSubscription: "Abonentë",
fDirEntry: "Hyrje",
fDirExit: "Dalje",
fSrcBooth: "Kabinë",
fSrcReader: "Lexues",
fEvtEntry: "Hyrje",
@@ -687,7 +686,7 @@ export const sq = {
starting: "Duke filluar…",
endShift: "Mbyll turnin",
ending: "Duke mbyllur…",
endConfirm: "Të mbyllet ky turn? Regjistrohet dhe printohet një Raport Z i nënshkruar.",
endConfirm: "",
drawer: "Arka:",
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
@@ -715,10 +714,9 @@ export const sq = {
card: "Kartë:",
srcTickets: "Bileta:",
srcSubscriptions: "Abonime:",
srcSubSales: "shitje",
srcSubWindow: "jashtë orarit",
drawerSection: "— Arka —",
openingFloat: "Bilanci fillestar:",
openingFloat: "Arka fillestare:",
cashTaken: "Para të marra:",
cashAdded: "Para të shtuara:",
cashRemoved: "Para të hequra:",
@@ -752,7 +750,6 @@ export const sq = {
card: "Kartë",
srcTickets: "Bileta",
srcSubscriptions: "Abonime",
srcSubSales: "shitje abonimesh",
srcSubWindow: "jashtë orarit",
expectedDrawer: "Gjëndje arke",
// Filter (admin only).
@@ -772,7 +769,7 @@ export const sq = {
current: "aktual",
// Expanded drawer detail.
drawerSection: "Arka",
openingFloat: "Bilanci fillestar",
openingFloat: "Arka fillestare",
cashTaken: "Para të marra",
cashAdded: "Para të shtuara",
cashRemoved: "Para të hequra",
+8 -7
View File
@@ -13,13 +13,14 @@ export function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle("theme-light", theme === "light");
}
/** Apply a font scale as a whole-UI ZOOM (`pct`% on the root). The app's type is pinned in
* px (`text-[12px]` etc.), which a root font-size would NOT scale — `zoom` scales everything
* uniformly (text, spacing, icons), exactly like the browser's Ctrl+/−, so the feed/session
* logs grow too. Clamped to the allowed band; no-op-safe to call repeatedly. */
/** Apply a font scale by setting the ROOT font-size (percent). The app's text is sized in
* rem (the `text-[…rem]` utilities + the .label/.input/.hint/.btn component classes all
* derive from the root), so only TEXT scales — viewport-locked layout (h-screen frame,
* max-h-[90vh] modals, vh units) is unaffected, so headers/footers never clip; taller
* content just scrolls its own container. NOT `zoom` (which scaled those vh boxes too and
* pushed modal chrome out of view). Clamped to the band; no-op-safe to call repeatedly. */
export function applyFontScale(pct: number): void {
const clamped = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(pct)));
// `zoom` is supported in all the booth's target browsers (Chromium/WebKit/modern FF).
// 1 = 100%. Reset to "" at base so we don't leave an inline override lying around.
document.documentElement.style.zoom = clamped === 100 ? "" : String(clamped / 100);
// 100% = the browser's 16px root. The app's rem units scale off this.
document.documentElement.style.fontSize = clamped === 100 ? "" : `${clamped}%`;
}
+21 -13
View File
@@ -68,7 +68,7 @@ function NavLink({ to, label }: { to: string; label: string }) {
return (
<Link
to={to}
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
className="px-2 py-1 text-[0.6875rem] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
>
{label}
</Link>
@@ -82,7 +82,7 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
<Link
to={to}
activeOptions={{ exact }}
className="border-b-2 border-transparent px-3 py-2 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
className="border-b-2 border-transparent px-3 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
>
{label}
</Link>
@@ -159,7 +159,7 @@ function LanguageToggle({
}
}
return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
{(["sq", "en"] as const).map((l) => (
<button
key={l}
@@ -205,7 +205,7 @@ function ThemeToggle({
}
}
return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
{(["dark", "light"] as const).map((th) => (
<button
key={th}
@@ -241,7 +241,7 @@ function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: Se
}
const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
<button type="button" className={btn} onClick={() => step(-FONT_SCALE_STEP)} disabled={active <= FONT_SCALE_MIN} title={t("common.fontSmaller")} aria-label={t("common.fontSmaller")}>
A−
</button>
@@ -316,14 +316,14 @@ function ShiftButton() {
disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={onClick}
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`}
>
{busy ? t("shift.opening") : label}
</button>
{!isOpen && (
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)}
{err && <span className="text-[10px] text-term-red">{err}</span>}
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
{confirmingClose && (
<CloseShiftConfirm
busy={busy}
@@ -358,7 +358,7 @@ function CloseShiftConfirm({
return (
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
<div className="text-[13px] tabular-nums">
<div className="text-[0.8125rem] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
@@ -370,12 +370,17 @@ function CloseShiftConfirm({
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubSales")} value={fmt(x.subscriptionSalesMinor)} sub />
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
part is broken out below it; subscription SALES is not (it's the remainder). */}
<span />
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
<span />
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div>
</>
@@ -396,10 +401,13 @@ function CloseShiftConfirm({
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span className={`text-[11px] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}>
<span
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
>
{label}
</span>
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
</div>
);
}
@@ -448,7 +456,7 @@ function RootLayout() {
<Link
to="/profile"
title={t("nav.profile")}
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
className="text-[0.6875rem] text-term-muted hover:text-term-text [&.active]:text-term-amber"
>
{user.username} · {user.roleName}
</Link>
+6 -6
View File
@@ -98,7 +98,7 @@ export function DeviceFooter() {
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-[11px]"
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")}
@@ -156,7 +156,7 @@ export function DeviceFooter() {
{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-[11px] font-semibold uppercase tracking-wider text-term-amber">
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{t("devices.issuesTitle")}
</span>
<button
@@ -177,13 +177,13 @@ export function DeviceFooter() {
<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-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
<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-[11px] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
{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>
+2 -2
View File
@@ -27,7 +27,7 @@ export function SegGroup<V extends string>({
key={v || "all"}
type="button"
onClick={() => onChange(v)}
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${
className={`px-2 py-0.5 text-[0.625rem] uppercase tracking-wider transition-colors ${
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
}`}
>
@@ -61,7 +61,7 @@ export function FilterBar({
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder={searchPlaceholder}
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[0.75rem] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
/>
{children}
</div>
+1 -1
View File
@@ -30,7 +30,7 @@ export function Modal({
aria-describedby={undefined}
>
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{title}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
+1 -1
View File
@@ -21,7 +21,7 @@ export function Panel({
>
{title && (
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
<h2 className="m-0 text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{title}
</h2>
{right}
+8 -8
View File
@@ -37,9 +37,9 @@ export function SnapshotStrip({ identity }: { identity: string }) {
const dirLabel = (dir: "entry" | "exit" | null): string =>
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
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-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
return <div className="text-[0.6875rem] text-term-muted">{t("pay.noSnapshots")}</div>;
return (
<>
@@ -50,15 +50,15 @@ export function SnapshotStrip({ identity }: { identity: string }) {
{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-[11px]"
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-[9px] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
<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-[10px] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
<span className="text-[0.625rem] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
)}
</span>
))}
@@ -81,7 +81,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
loading="lazy"
/>
<span
className={`text-[9px] uppercase tracking-wider ${
className={`text-[0.5625rem] uppercase tracking-wider ${
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
}`}
>
@@ -100,8 +100,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
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-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</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>
+1 -1
View File
@@ -19,7 +19,7 @@ export function StatusDot() {
const { t } = useTranslation();
const status = useLiveStore((s) => s.status);
return (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
<span className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-term-muted">
<span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/>
+39 -47
View File
@@ -95,9 +95,9 @@ export function displayIdentity(e: LedgerEvent): string {
return e.subscriberLabel ?? e.identity ?? "—";
}
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps
* the time/label/identity/index columns aligned across rows; the detail line lives in
* its own row, indented under the identity column. */
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the
* time/label/#index columns aligned across rows; the identity, plate, badges and reason flow
* inline in the middle column and wrap there only when they run out of width. */
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
const { t } = useTranslation();
const style = eventStyleFor(e);
@@ -110,52 +110,44 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
const reason = renderReason(p, t);
const amount = paymentSummary(p);
const badges = eventBadges(p);
const via = viaKey(p);
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
const showDetail = detail != null || badges.length > 0 || via != null;
return (
<button
type="button"
onClick={() => onOpen(e)}
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-start gap-x-3 border-b border-term-border/50 px-1 py-1 text-left text-[0.75rem] tabular-nums hover:bg-term-panel-2 ${
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
}`}
>
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-term-text">{displayIdentity(e)}</span>
<span className="py-px text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`py-px shrink-0 font-semibold ${style.color}`}>{label}</span>
{/* Identity + plate + detail all flow in ONE wrapping line — they fill the available
width and only wrap to a second line when this cell actually runs out of room (no
forced second row). Keeps time/label/#index in their columns. */}
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 py-px">
<span className="break-all text-term-text">{displayIdentity(e)}</span>
{e.plate && (
<span
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
className="shrink-0 rounded border border-term-border px-1 text-[0.6875rem] font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")}
>
{e.plate}
</span>
)}
{badges.map((k) => (
<span
key={k}
className="shrink-0 rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
))}
{detail && (
<span className={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
</span>
<span className="text-term-muted">#{e.index}</span>
{showDetail && (
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
{badges.map((k) => (
<span
key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
))}
{via && (
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
{t(via)}
</span>
)}
{detail && (
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
</div>
)}
<span className="py-px text-term-muted">#{e.index}</span>
</button>
);
}
@@ -163,8 +155,8 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
/** One label/value line in the event-detail modal. */
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[0.75rem]">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
<span className="min-w-0 break-words text-term-text">{children}</span>
</div>
);
@@ -203,19 +195,19 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
{(reason || money) && (
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
<div className={`mt-1 text-[0.8125rem] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
{reason ?? money}
</div>
)}
{!reason && !money && isAnomaly && (
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
<div className="mt-1 text-[0.8125rem] text-term-red/90">{t("booth.evtNoReason")}</div>
)}
{badges.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{badges.map((k) => (
<span
key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
@@ -235,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
(the SUBSESS-… session key) for traceability against the ledger. */}
{e.subscriberLabel && e.identity && (
<DetailRow label={t("booth.edOccurrence")}>
<code className="text-[11px] text-term-muted">{e.identity}</code>
<code className="text-[0.6875rem] text-term-muted">{e.identity}</code>
</DetailRow>
)}
{money && (
@@ -257,7 +249,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
)}
{tariffVersionId && (
<DetailRow label={t("booth.edTariffVersion")}>
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
<code className="text-[0.6875rem] text-term-muted">{tariffVersionId}</code>
</DetailRow>
)}
</div>
@@ -265,7 +257,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
{/* The entry/exit evidence images for this session's identity. */}
{e.identity && (
<div>
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
<div className="mb-1.5 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
<SnapshotStrip identity={e.identity} />
</div>
)}
@@ -275,28 +267,28 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
operator's; tucking them behind a disclosure keeps the common view clean
while preserving the tamper-evidence trail on demand. */}
<details className="rounded-term border border-term-border bg-term-panel-2">
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
<summary className="cursor-pointer select-none px-3 py-2 text-[0.6875rem] uppercase tracking-wider text-term-muted hover:text-term-text">
{t("booth.edAuditData")}
</summary>
<div className="border-t border-term-border px-3 pb-3 pt-1">
<DetailRow label={t("booth.edSignature")}>
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
<code className="break-all text-[0.6875rem] text-term-muted">{e.signature}</code>
</DetailRow>
<DetailRow label={t("booth.edKeyId")}>
<code className="text-[11px] text-term-muted">{e.keyId}</code>
<code className="text-[0.6875rem] text-term-muted">{e.keyId}</code>
</DetailRow>
<DetailRow label={t("booth.edPrevHash")}>
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
<code className="break-all text-[0.6875rem] text-term-muted">{e.prevHash ?? "—"}</code>
</DetailRow>
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
<div className="mb-1.5 mt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("booth.edRawPayload")}
</div>
{p && Object.keys(p).length > 0 ? (
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[0.6875rem] text-term-text">
{JSON.stringify(p, null, 2)}
</pre>
) : (
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
<div className="text-[0.75rem] text-term-muted">{t("booth.edNoPayload")}</div>
)}
</div>
</details>
+13
View File
@@ -1829,3 +1829,16 @@ Three booth fixes + one prefs feature:
the theme-pref pattern end to end (PUT /api/auth/font-scale, sessionView, setFontScalePref,
applyFontScale in App). i18n sq+en. Tests: 4 font-scale auth-route cases (persist+/me, clamp/
snap, 400, default). Full workspace build/lint/test green (189 server tests).
## [2026-06-28] fix | Font scale: rem-based root scaling (CSS `zoom` broke modal/footer layout)
The first cut of the per-user font scale used CSS `zoom` on the root so it would scale the app's
px-pinned type (text-[12px] etc.). But `zoom` scales the WHOLE box model including viewport-locked
containers — the h-screen app frame and max-h-[90vh] modals — so at 130% they overflowed the viewport
and modal headers/footers were pushed out of view (user had to scroll, big-modal chrome hidden).
Reworked to the correct fix: converted ALL `text-[Npx]` font utilities to rem across the web app
(~230 sites in 25 .tsx files + the .label/.hint/.btn component classes + body in index.css; 16px root
→ 12px=0.75rem etc., so 100% is visually identical), and applyFontScale now sets the ROOT font-size
(percent) instead of zoom. Only TEXT scales; vh/h-screen layout stays viewport-locked, so modals cap
at 90vh and scroll their own body — chrome never clips. Verified with Playwright: at 130% root, sample
text 12px→15.6px while the h-screen frame stayed exactly viewport-height and 90vh resolved unchanged.
Full workspace build/lint/test green.