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 --", "-- Arkëtime sipas burimit --",
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`, `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}`, `Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`, ` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
"", "",
"-- Arka --", "-- Arka --",
+58 -67
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. // reconciles via the pay/exit modal — never a free barrier open.
// See wiki/concepts/booth-exit-flow.md. // See wiki/concepts/booth-exit-flow.md.
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
type KindFilter = "transient" | "subscription"; 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 }) { export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); 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); 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 [search, setSearch] = useState("");
const [status, setStatus] = useState<StatusFilter | "">("");
const [kind, setKind] = useState<KindFilter | "">(""); const [kind, setKind] = useState<KindFilter | "">("");
const sessions = useMemo(() => data?.sessions ?? [], [data]); const sessions = useMemo(() => data?.sessions ?? [], [data]);
@@ -81,7 +63,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
return sessions.filter((s) => { return sessions.filter((s) => {
if (kind === "transient" && s.subscription) return false; if (kind === "transient" && s.subscription) return false;
if (kind === "subscription" && !s.subscription) return false; if (kind === "subscription" && !s.subscription) return false;
if (status && statusOf(s) !== status) return false;
if (q) { if (q) {
// Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits. // Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits.
const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase(); const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase();
@@ -89,14 +70,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
} }
return true; 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>[] = [ const kindOpts: SegOption<KindFilter>[] = [
{ value: "transient", label: t("booth.fKindTransient") }, { value: "transient", label: t("booth.fKindTransient") },
{ value: "subscription", label: t("booth.fKindSubscription") }, { value: "subscription", label: t("booth.fKindSubscription") },
@@ -120,7 +95,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
<Panel <Panel
title={t("booth.activeSessions")} title={t("booth.activeSessions")}
right={ 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}
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")} {filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
</span> </span>
@@ -129,7 +104,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}> <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")} /> <SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
</FilterBar> </FilterBar>
@@ -143,23 +117,46 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
: t("booth.noMatch")} : t("booth.noMatch")}
</div> </div>
) : ( ) : (
filtered.map((s) => { // A real table — aligned columns (who · plate · entry · elapsed · action). No
const badge = statusBadge(s); // 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; 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 ( return (
<div <tr
key={s.identity} 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)} onClick={() => onPick(s.identity)}
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber" className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
s.overstay ? "bg-term-red/5" : ""
}`}
title={t("booth.openPayExit")} title={t("booth.openPayExit")}
> >
<span className="text-term-text"> <td className="px-2 py-1.5 text-term-text">
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity} {s.subscription ? (
</span> <span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
) : (
s.identity
)}
</td>
<td className="px-2 py-1.5">
{s.plate && ( {s.plate && (
<span <span
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber" className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
@@ -168,45 +165,39 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
{s.plate} {s.plate}
</span> </span>
)} )}
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span> </td>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span> <td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
<span {formatRelativeDateTime(s.enteredAt, t)}
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`} </td>
title={badge.titleKey ? t(badge.titleKey) : undefined} <td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
> {formatDuration(s.enteredAt, new Date().toISOString())}
{t(badge.key)} </td>
</span> <td className="px-2 py-1.5 text-right">
</button> {canReopen && (
{/* 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 <button
type="button" type="button"
disabled={reopen.isPending || !shiftReady} disabled={reopen.isPending || !shiftReady}
onClick={() => handleReopen(s)} onClick={(e) => {
className="btn btn-pay btn-sm shrink-0" 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")} title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
> >
{t("booth.openBarrier")} {t("booth.openBarrier")}
</button> </button>
) : (
<span className="w-[88px] shrink-0" />
)} )}
{msg && ( {msg && (
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}> <span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
{msg.text} {msg.text}
</span> </span>
)} )}
</div> </td>
</tr>
); );
}) })}
</tbody>
</table>
)} )}
</div> </div>
</div> </div>
+15 -15
View File
@@ -226,7 +226,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
aria-describedby={undefined} aria-describedby={undefined}
> >
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2"> <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 {isSubscription
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}` ? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
: `${t("pay.ticket")} ${identity}`} : `${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"> <div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
{blockedByOther ? ( {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")} {t("shift.gateOtherTitle")}
</div> </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 ?? "?" })} {t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
</div> </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")} {t("shift.gateTitle")}
</div> </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 <button
type="button" type="button"
onClick={handleOpenShift} 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 out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */} 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"> <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")} {subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
</span> </span>
<span className="text-3xl font-bold text-term-cyan"> <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 gate; once paid, prompt the operator to open the barrier; a prepaid
subscriber sees the assist explanation only after revealing it. */} subscriber sees the assist explanation only after revealing it. */}
{subWindowDue && !windowPaid ? ( {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")} {t("pay.windowChargeHint")}
</div> </div>
) : isSubscription && windowPaid ? ( ) : 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")} {t("pay.windowPaidHint")}
</div> </div>
) : isSubscription && assistRevealed ? ( ) : 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")} {t("pay.subAssistHint")}
</div> </div>
) : null} ) : null}
{/* For an overstay, explain why a top-up is required (no free exit). */} {/* For an overstay, explain why a top-up is required (no free exit). */}
{isOverstay && ( {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")} {t("pay.overstayHint")}
</div> </div>
)} )}
@@ -368,7 +368,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
subscriber window charge that's still unpaid). */} subscriber window charge that's still unpaid). */}
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && ( {phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
<div className="flex items-center gap-2"> <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) => ( {(["cash", "card"] as const).map((tn) => (
<button <button
key={tn} key={tn}
@@ -384,7 +384,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */} {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
{phase !== "done" && !isSubscription && ( {phase !== "done" && !isSubscription && (
<label className="flex items-center gap-2 text-[12px]"> <label className="flex items-center gap-2 text-[0.75rem]">
<input <input
type="checkbox" type="checkbox"
className="accent-term-amber" className="accent-term-amber"
@@ -401,10 +401,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
signed `void` event — the entry is never edited. */} signed `void` event — the entry is never edited. */}
{voiding && phase !== "done" && ( {voiding && phase !== "done" && (
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2"> <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")} {t("pay.cancelTicketTitle")}
</div> </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"> <div className="mt-2 flex flex-wrap gap-1.5">
{(["misprint", "test", "wrongVehicle"] as const).map((k) => ( {(["misprint", "test", "wrongVehicle"] as const).map((k) => (
<button <button
@@ -560,7 +560,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) { function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
return ( return (
<div className="flex items-baseline justify-between"> <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> <span className={`text-sm ${valueClass}`}>{value}</span>
</div> </div>
); );
+8 -14
View File
@@ -49,13 +49,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
<div className="flex items-end gap-4"> <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="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
<div className="pb-1 text-term-muted"> <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"> <div className="text-sm tabular-nums">
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`} {occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
</div> </div>
</div> </div>
<div className="ml-auto text-right"> <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"}`}> <div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
{occ.free == null ? "∞" : occ.free} {occ.free == null ? "∞" : occ.free}
</div> </div>
@@ -67,7 +67,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
</div> </div>
)} )}
{occ.full && ( {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")} {t("booth.lotFull")}
</div> </div>
)} )}
@@ -106,7 +106,7 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
className="input h-11 flex-1 px-3 text-lg tabular-nums" className="input h-11 flex-1 px-3 text-lg tabular-nums"
/> />
<button type="submit" className="btn btn-primary btn-lg"> <button type="submit" className="btn btn-primary btn-lg">
{t("booth.open")} {t("booth.openTicket")}
</button> </button>
</form> </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" /> <circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
</svg> </svg>
<div className="leading-tight"> <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 className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
</div> </div>
</div> </div>
@@ -181,10 +181,10 @@ export function BoothScreen() {
// abandon an in-progress payment (the operator finishes/closes, then scans the next). // abandon an in-progress payment (the operator finishes/closes, then scans the next).
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null }); 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 [feedSearch, setFeedSearch] = useState("");
const [feedType, setFeedType] = useState<FeedCat | "">(""); const [feedType, setFeedType] = useState<FeedCat | "">("");
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">(""); const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
// Live overlays from the WS store. // Live overlays from the WS store.
@@ -212,7 +212,6 @@ export function BoothScreen() {
const fq = feedSearch.trim().toLowerCase(); const fq = feedSearch.trim().toLowerCase();
const events = scoped.filter((e) => { const events = scoped.filter((e) => {
if (feedType && feedCat(e.type) !== feedType) return false; if (feedType && feedCat(e.type) !== feedType) return false;
if (feedDir && e.direction !== feedDir) return false;
if (feedSrc) { if (feedSrc) {
const isBooth = e.source === "manual"; const isBooth = e.source === "manual";
if (feedSrc === "booth" ? !isBooth : isBooth) return false; if (feedSrc === "booth" ? !isBooth : isBooth) return false;
@@ -231,10 +230,6 @@ export function BoothScreen() {
{ value: "void", label: t("booth.fEvtVoid") }, { value: "void", label: t("booth.fEvtVoid") },
{ value: "anomaly", label: t("booth.fEvtAnomaly") }, { 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">[] = [ const feedSrcOpts: SegOption<"booth" | "reader">[] = [
{ value: "booth", label: t("booth.fSrcBooth") }, { value: "booth", label: t("booth.fSrcBooth") },
{ value: "reader", label: t("booth.fSrcReader") }, { value: "reader", label: t("booth.fSrcReader") },
@@ -272,7 +267,7 @@ export function BoothScreen() {
<Panel <Panel
title={t("booth.liveFeed")} title={t("booth.liveFeed")}
right={ 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}
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")} {events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
</span> </span>
@@ -288,7 +283,6 @@ export function BoothScreen() {
onChange={setFeedType} onChange={setFeedType}
allLabel={t("booth.filterAll")} 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")} /> <SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
</FilterBar> </FilterBar>
)} )}
+1 -1
View File
@@ -46,7 +46,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
autoComplete="current-password" autoComplete="current-password"
/> />
</div> </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}> <button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
{busy ? t("auth.signingIn") : t("auth.signIn")} {busy ? t("auth.signingIn") : t("auth.signIn")}
</button> </button>
+7 -7
View File
@@ -31,7 +31,7 @@ function LogRow({ log }: { log: AppLogRecord }) {
<button <button
type="button" type="button"
onClick={() => hasDetail && setOpen((v) => !v)} 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" hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
}`} }`}
> >
@@ -44,17 +44,17 @@ function LogRow({ log }: { log: AppLogRecord }) {
{open && hasDetail && ( {open && hasDetail && (
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2"> <div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
{log.path && ( {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> {t("logs.path")}: <code className="text-term-text">{log.path}</code>
</div> </div>
)} )}
{log.context && Object.keys(log.context).length > 0 && ( {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)} {JSON.stringify(log.context, null, 2)}
</pre> </pre>
)} )}
{log.stack && ( {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} {log.stack}
</pre> </pre>
)} )}
@@ -136,12 +136,12 @@ export function LogsViewer() {
<div className="card p-2"> <div className="card p-2">
{q.isLoading ? ( {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 ? ( ) : 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.time")}</span>
<span>{t("logs.level")}</span> <span>{t("logs.level")}</span>
<span>{t("logs.source")}</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"> <h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.accountSection")} {t("profile.accountSection")}
</h2> </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> <div>
<span className="block">{t("profile.username")}</span> <span className="block">{t("profile.username")}</span>
<span className="text-sm text-term-text">{user.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> <span className="text-sm text-term-text">{user.roleName}</span>
</div> </div>
</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")} {t("profile.fullName")}
<input <input
className="input" className="input"
@@ -101,7 +101,7 @@ export function Profile({
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setFullName(e.target.value)}
/> />
</label> </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")} {t("profile.email")}
<input <input
className="input" className="input"
@@ -115,7 +115,7 @@ export function Profile({
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}> <button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
{t("profile.saveProfile")} {t("profile.saveProfile")}
</button> </button>
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>} {accountMsg && <span className="text-[0.6875rem] text-term-muted">{accountMsg}</span>}
</div> </div>
</section> </section>
@@ -124,7 +124,7 @@ export function Profile({
<h2 className="text-sm uppercase tracking-wider text-term-muted"> <h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.passwordSection")} {t("profile.passwordSection")}
</h2> </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")} {t("profile.currentPassword")}
<input <input
className="input" className="input"
@@ -134,7 +134,7 @@ export function Profile({
onChange={(e) => setCurrent(e.target.value)} onChange={(e) => setCurrent(e.target.value)}
/> />
</label> </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")} {t("profile.newPassword")}
<input <input
className="input" className="input"
@@ -144,7 +144,7 @@ export function Profile({
onChange={(e) => setNext(e.target.value)} onChange={(e) => setNext(e.target.value)}
/> />
</label> </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")} {t("profile.confirmPassword")}
<input <input
className="input" className="input"
@@ -163,7 +163,7 @@ export function Profile({
> >
{t("profile.changePassword")} {t("profile.changePassword")}
</button> </button>
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>} {pwMsg && <span className="text-[0.6875rem] text-term-muted">{pwMsg}</span>}
</div> </div>
</section> </section>
</div> </div>
+7 -7
View File
@@ -76,13 +76,13 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{t("recycleBin.title")} {t("recycleBin.title")}
</h1> </h1>
{retentionDays > 0 && ( {retentionDays > 0 && (
<span className="text-[12px] text-term-muted"> <span className="text-[0.75rem] text-term-muted">
{t("recycleBin.retentionNote", { days: retentionDays })} {t("recycleBin.retentionNote", { days: retentionDays })}
</span> </span>
)} )}
</div> </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 && <p className="text-term-muted">{t("common.loading")}</p>}
{!binQ.isLoading && items.length === 0 ? ( {!binQ.isLoading && items.length === 0 ? (
@@ -90,9 +90,9 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{t("recycleBin.empty")} {t("recycleBin.empty")}
</p> </p>
) : ( ) : (
<table className="w-full text-[13px]"> <table className="w-full text-[0.8125rem]">
<thead> <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.type")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</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> <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) => ( {items.map((it) => (
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50"> <tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
<td className="py-1.5 pr-3"> <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])} {t(KIND_KEY[it.kind])}
</span> </span>
</td> </td>
@@ -146,10 +146,10 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
{purging && ( {purging && (
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}> <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 })} {t("recycleBin.purgeConfirmBody", { label: purging.label })}
</p> </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"> <div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}> <button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
{t("common.cancel")} {t("common.cancel")}
+6 -6
View File
@@ -95,7 +95,7 @@ export function Reports() {
</button> </button>
))} ))}
</div> </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> <span>{t("reports.groupBy")}</span>
<select <select
className="select input-sm w-auto" className="select input-sm w-auto"
@@ -247,7 +247,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
{/* Cash / card + duration + subscription breakdown (numbers). */} {/* Cash / card + duration + subscription breakdown (numbers). */}
<Panel title={t("reports.chart.breakdown")}> <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.cash")} value={money(tot.cashMinor)} />
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} /> <Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} /> <Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
@@ -262,7 +262,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</Panel> </Panel>
</div> </div>
<p className="text-[11px] text-term-muted"> <p className="text-[0.6875rem] text-term-muted">
{t("reports.footnote", { tz: data.tz })} {t("reports.footnote", { tz: data.tz })}
</p> </p>
</div> </div>
@@ -290,7 +290,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
: "text-term-text"; : "text-term-text";
return ( return (
<div className="rounded-term border border-term-border bg-term-panel p-2.5"> <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 className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
</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 }) { function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div className="rounded-term border border-term-border bg-term-panel p-3"> <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} {children}
</div> </div>
); );
@@ -315,5 +315,5 @@ function Row({ label, value }: { label: string; value: string }) {
} }
function Empty({ t }: { t: TFunction }) { 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> </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 <Modal
open={editing != null} 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 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 justify-between">
<div className="flex items-center gap-2"> <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 && ( {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")} {t("roles.builtin")}
</span> </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 })} {t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span> </span>
</div> </div>
@@ -155,11 +155,11 @@ function RoleEditor({
<div className="mt-1 grid grid-cols-1 gap-1"> <div className="mt-1 grid grid-cols-1 gap-1">
{Object.entries(grouped).map(([resource, list]) => ( {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"> <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) => { {list.map((p) => {
const action = p.split(":")[1]!; const action = p.split(":")[1]!;
return ( 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)} /> <input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
{action} {action}
</label> </label>
+40 -40
View File
@@ -206,8 +206,8 @@ function CategorySection({
{warnings.length > 0 && ( {warnings.length > 0 && (
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2"> <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> <strong className="text-[0.75rem] text-term-amber">{t("setup.warnTitle")}</strong>
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber"> <ul className="mt-1 list-disc pl-5 text-[0.75rem] text-term-amber">
{warnings.map((w, i) => ( {warnings.map((w, i) => (
<li key={i}>{w}</li> <li key={i}>{w}</li>
))} ))}
@@ -233,7 +233,7 @@ function CategorySection({
)} )}
{blockedNoController ? ( {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")}> <button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })} {assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
@@ -301,7 +301,7 @@ function AssignmentRow({
} }
return ( 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> <strong className="text-term-text">{assignment.driverId}</strong>
{host && <span className="tabular-nums text-term-muted">{host}</span>} {host && <span className="tabular-nums text-term-muted">{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} /> <DeviceSummary assignment={assignment} controllers={controllers} />
@@ -690,19 +690,19 @@ function DeviceForm({
{selected && ( {selected && (
<div className="mt-3"> <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 && ( {canDiscover && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <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}> <button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
{scanning ? t("setup.scanning") : t("setup.scan")} {scanning ? t("setup.scanning") : t("setup.scan")}
</button> </button>
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>} {scanError && <span className="ml-2 text-[0.75rem] text-term-red">{scanError}</span>}
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>} {found && found.length === 0 && <p className="mt-2 text-[0.75rem] text-term-muted">{t("setup.noControllersFound")}</p>}
{found && found.length > 0 && ( {found && found.length > 0 && (
<ul className="mt-2 list-none p-0"> <ul className="mt-2 list-none p-0">
{found.map((d) => ( {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)}> <button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
{t("setup.use")} {t("setup.use")}
</button> </button>
@@ -734,7 +734,7 @@ function DeviceForm({
f.type === "boolean" ? ( f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not // 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. // 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 <input
type="checkbox" type="checkbox"
className="mt-0.5" className="mt-0.5"
@@ -859,7 +859,7 @@ function DeviceForm({
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */} {/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
{isCamera && ( {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 <input
type="checkbox" type="checkbox"
className="mt-0.5" 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 (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. */} the exit cam's back-plate read would phantom-exit a car that just entered. */}
{isCamera && anpr && ( {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 <input
type="checkbox" type="checkbox"
className="mt-0.5" className="mt-0.5"
@@ -901,7 +901,7 @@ function DeviceForm({
a Test connection first. We surface each field separately, matching the a Test connection first. We surface each field separately, matching the
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */} camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
{isCamera && Boolean(config.alarmPushEnabled) && ( {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> <div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
{!editing?.id ? ( {!editing?.id ? (
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p> <p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
@@ -964,9 +964,9 @@ function DeviceForm({
)} )}
</div> </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 && ( {tested && (
<div className="mt-2 text-[12px]"> <div className="mt-2 text-[0.75rem]">
<div className="text-term-text"> <div className="text-term-text">
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} /> {t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>} {tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
@@ -993,10 +993,10 @@ function DeviceForm({
</button> </button>
<p className="hint mt-1">{t("setup.testAnprHint")}</p> <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 &&
(anprResult.ok ? ( (anprResult.ok ? (
<div className="mt-2 text-[12px] text-term-green"> <div className="mt-2 text-[0.75rem] text-term-green">
{t("setup.anprOk", { {t("setup.anprOk", {
plate: anprResult.plate, plate: anprResult.plate,
confidence: Math.round(anprResult.confidence * 100), confidence: Math.round(anprResult.confidence * 100),
@@ -1007,7 +1007,7 @@ function DeviceForm({
)} )}
</div> </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 })} ⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>} {anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
{anprResult.tookMs != null && ( {anprResult.tookMs != null && (
@@ -1027,14 +1027,14 @@ function DeviceForm({
</button> </button>
<p className="hint mt-1">{t("setup.testPrintHint")}</p> <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 &&
(printResult.ok ? ( (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 })} {t("setup.printOk", { ms: printResult.tookMs })}
</div> </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 })} ⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>} {printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
</div> </div>
@@ -1060,12 +1060,12 @@ function DeviceForm({
</select> </select>
</div> </div>
{!backendIps.some((c) => c.onDeviceSubnet) && ( {!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> <p className="hint mt-1">{t("setup.backendIpHint")}</p>
</div> </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>
)} )}
</div> </div>
@@ -1105,11 +1105,11 @@ function OutputEditor({
return ( return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <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> <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). */} {/* 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")} {t("setup.pulseOpenMs")}
<input <input
type="number" type="number"
@@ -1125,7 +1125,7 @@ function OutputEditor({
barriers pulse (their button/presence terminals are in the Inputs section). */} barriers pulse (their button/presence terminals are in the Inputs section). */}
{relays.map((r, i) => ( {relays.map((r, i) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2"> <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")} {t("setup.relay")}
<input <input
type="number" type="number"
@@ -1150,7 +1150,7 @@ function OutputEditor({
{/* Alert relay: which input fires the blink + the blink cadence. */} {/* Alert relay: which input fires the blink + the blink cadence. */}
{r.direction === "radarAlert" && ( {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")} {t("setup.triggerInput")}
<input <input
type="number" type="number"
@@ -1161,7 +1161,7 @@ function OutputEditor({
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })} onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
/> />
</label> </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")} {t("setup.lockLane")}
<select <select
className="select input-sm w-auto" className="select input-sm w-auto"
@@ -1172,7 +1172,7 @@ function OutputEditor({
<option value="exit">{t("setup.lockLaneExit")}</option> <option value="exit">{t("setup.lockLaneExit")}</option>
</select> </select>
</label> </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")} {t("setup.blinkOnMs")}
<input <input
type="number" type="number"
@@ -1183,7 +1183,7 @@ function OutputEditor({
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })} onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
/> />
</label> </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")} {t("setup.blinkOffMs")}
<input <input
type="number" type="number"
@@ -1251,11 +1251,11 @@ function InputEditor({
return ( return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <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> <p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */} {/* 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 <input
type="checkbox" type="checkbox"
className="mt-0.5" className="mt-0.5"
@@ -1274,7 +1274,7 @@ function InputEditor({
const isButton = row.role === "button"; const isButton = row.role === "button";
return ( return (
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2"> <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")} {t("setup.inputTerminal")}
<input <input
type="number" type="number"
@@ -1298,7 +1298,7 @@ function InputEditor({
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */} {/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
{row.role !== "alertTrigger" && ( {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")} {t("setup.inputServesRelay")}
<select <select
className="select input-sm w-auto" className="select input-sm w-auto"
@@ -1319,7 +1319,7 @@ function InputEditor({
{/* Presence: active-low (a radar wired opposite the button). */} {/* Presence: active-low (a radar wired opposite the button). */}
{isPresence && ( {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 <input
type="checkbox" type="checkbox"
checked={!!row.activeLow} checked={!!row.activeLow}
@@ -1331,7 +1331,7 @@ function InputEditor({
{/* Button cooldown fallback — only when no presence sensor serves this relay. */} {/* Button cooldown fallback — only when no presence sensor serves this relay. */}
{isButton && !hasPresenceFor(row.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")} {t("setup.entryCooldown")}
<input <input
type="number" type="number"
@@ -1381,9 +1381,9 @@ function BindingPicker({
return ( return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <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"> <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")} {t("setup.controller")}
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}> <select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
<option value="" disabled> <option value="" disabled>
@@ -1400,7 +1400,7 @@ function BindingPicker({
})} })}
</select> </select>
</label> </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")} {t("setup.relay")}
<select <select
className="select input-sm w-auto" 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]) })} />} {chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
</div> </div>
{controller && relays.length === 0 && ( {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> </div>
); );
@@ -1441,7 +1441,7 @@ function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: s
? "border-term-red text-term-red" ? "border-term-red text-term-red"
: "border-term-muted text-term-muted"; : "border-term-muted text-term-muted";
return ( 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} {label ?? direction}
</span> </span>
); );
+33 -26
View File
@@ -175,7 +175,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
</div> </div>
{q.isError && ( {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 {/* 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="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"> <div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
{!q.isLoading && list.length === 0 && ( {!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) => ( {list.map((s) => (
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} /> <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} 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>
</div> </div>
@@ -228,7 +228,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
} }
return ( return (
<span className="flex items-center gap-2"> <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}> <button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
{busy ? t("shift.starting") : t("shift.startShift")} {busy ? t("shift.starting") : t("shift.startShift")}
</button> </button>
@@ -244,11 +244,11 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
<button <button
type="button" type="button"
onClick={onClick} 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"> <div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 font-semibold text-term-text"> <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)} {showOperator ? s.operator : when(s.startedAt)}
</span> </span>
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span> <span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
@@ -300,9 +300,9 @@ function ShiftActivityLog({
return ( return (
<div className="flex h-full min-h-0 flex-col"> <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="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"> <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} · `} {showOperator && `${shift.operator} · `}
{formatRelativeDateTime(shift.startedAt, t)} {formatRelativeDateTime(shift.startedAt, t)}
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`} {!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
@@ -316,10 +316,10 @@ function ShiftActivityLog({
</span> </span>
)} )}
</div> </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.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, 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.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} /> <Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} /> <Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
@@ -331,8 +331,8 @@ function ShiftActivityLog({
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-1"> <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 && <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-[12px] text-term-muted">{t("shifts.noActivity")}</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) => ( {events.map((e) => (
<EventRow key={e.id} e={e} onOpen={setDetailEvent} /> <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"> <Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
{report ? ( {report ? (
// Result — the signed Z-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="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"> <div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(report.paymentCount)} /> <Figure label={t("shift.payments")} value={String(report.paymentCount)} />
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} /> <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.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 /> <Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
</div> </div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1"> <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> </div>
) : ( ) : (
// Confirm — show the live takings (split by source) + drawer before closing. // 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> <p className="text-term-muted">{t("shift.endConfirm")}</p>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5"> <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.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, 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 /> <Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2"> <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.cash")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, 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 /> <Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
</div> </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"> <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" onClick={onClose}>{t("subs.cancel")}</button>
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}> <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 ( return (
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md"> <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"> <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 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")} /> <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" 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" /> <input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
</div> </div>
<div className="text-[11px] text-term-muted">{t("shift.voucherHint")}</div> <div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
{msg && <div className="text-[12px] text-term-muted">{msg}</div>} {msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
<div className="mt-1 flex justify-end gap-2"> <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-sm" onClick={onClose}>{t("common.close")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</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 ( return (
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md"> <Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
{!x ? ( {!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="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"> <div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(x.paymentCount)} /> <Figure label={t("shift.payments")} value={String(x.paymentCount)} />
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} /> <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.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 /> <Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
</div> </div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1"> <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.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold /> <Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
</div> </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"> <div className="mt-3 flex justify-end">
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button> <button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
</div> </div>
@@ -513,8 +519,9 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return ( return (
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}> <div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span> <span className={`whitespace-nowrap ${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> </div>
); );
} }
+6 -6
View File
@@ -69,7 +69,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
return ( return (
<section className="card mt-6 max-w-md p-4"> <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> <strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
{occ == null ? ( {occ == null ? (
<span className="text-term-muted">…</span> <span className="text-term-muted">…</span>
@@ -93,7 +93,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="label">{t("site.capacityLabel")}</span> <span className="label">{t("site.capacityLabel")}</span>
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} /> <input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
</div> </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 <input
type="checkbox" type="checkbox"
className="accent-term-amber" className="accent-term-amber"
@@ -103,7 +103,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
{t("site.printExitDefault")} {t("site.printExitDefault")}
<span className="hint">{t("site.printExitHint")}</span> <span className="hint">{t("site.printExitHint")}</span>
</label> </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 <input
type="checkbox" type="checkbox"
className="mt-0.5 accent-term-amber" 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 className="hint block">{t("site.reserveSubsHint")}</span>
</span> </span>
</label> </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 <input
type="checkbox" type="checkbox"
className="mt-0.5 accent-term-amber" 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 className="hint block">{t("site.anprEntryHint")}</span>
</span> </span>
</label> </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")} {t("site.parkDetails")}
</div> </div>
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => ( {META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
@@ -153,7 +153,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
))} ))}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button> <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>
</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> <h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
<ul className="mb-3 list-none p-0"> <ul className="mb-3 list-none p-0">
{subs.map((s) => ( {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> <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={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> <span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
@@ -464,13 +464,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
</option> </option>
))} ))}
</select> </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> </span>
</> </>
) : ( ) : (
<> <>
<label className="label">{t("subs.plan")}</label> <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 {/* 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 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 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 (cur) versions.unshift(cur);
} }
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) { 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 ( return (
<> <>
@@ -504,7 +512,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
</option> </option>
))} ))}
</select> </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> </span>
</> </>
); );
@@ -523,7 +531,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
inputMode="numeric" inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))} 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> </span>
</> </>
)} )}
@@ -533,7 +541,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
<> <>
<label className="label">{t("subs.tender")}</label> <label className="label">{t("subs.tender")}</label>
<span className="flex items-center gap-3"> <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 <input
type="radio" type="radio"
name="tender" name="tender"
@@ -543,7 +551,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
/> />
{t("subs.tenderCash")} {t("subs.tenderCash")}
</label> </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 <input
type="radio" type="radio"
name="tender" name="tender"
@@ -553,13 +561,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
/> />
{t("subs.tenderCard")} {t("subs.tenderCard")}
</label> </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> </span>
</> </>
)} )}
<label className="label">{t("subs.carLimit")}</label> <label className="label">{t("subs.carLimit")}</label>
<span className="flex items-center gap-3"> <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")} <input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label> </label>
{form.carBound && ( {form.carBound && (
@@ -580,7 +588,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
inputMode="numeric" inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))} 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])} × {t(PERIOD_KEY[selectedPlan.period])}
</span> </span>
</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 {/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
override it — this is exactly what will be charged + signed. */} override it — this is exactly what will be charged + signed. */}
{editing === "new" && form.planId.trim() !== "" && ( {editing === "new" && form.planId.trim() !== "" && (
<span className="text-[12px] text-term-cyan"> <span className="text-[0.75rem] text-term-cyan">
{quoting {quoting
? t("subs.quoting") ? t("subs.quoting")
: quote : 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")} /> <input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div> </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) => ( {form.credentials.map((c, i) => (
<div key={i} className="mb-1.5 flex items-center gap-2"> <div key={i} className="mb-1.5 flex items-center gap-2">
{/* Operator chooses the credential type: QR (auto-generated) or RFID {/* Operator chooses the credential type: QR (auto-generated) or RFID
@@ -625,7 +633,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
c.value.trim() ? ( c.value.trim() ? (
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly /> <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" // 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 {/* Capture panel: pick a reader, present the card; the captured value fills
the credential. The OTHER reader keeps serving the live flow. */} the credential. The OTHER reader keeps serving the live flow. */}
{capture && ( {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" ? ( {capture.phase === "pick" ? (
<> <>
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div> <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> <button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div> </div>
</Modal> </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> </section>
); );
} }
+18 -18
View File
@@ -237,19 +237,19 @@ export function SubscriptionPlansManager() {
return ( return (
<section className="px-4 py-6"> <section className="px-4 py-6">
<div className="mb-3 flex items-center justify-between"> <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())}> <button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
{t("plans.add")} {t("plans.add")}
</button> </button>
</div> </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 && ( {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 ? ( {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"> <div className="flex flex-col gap-2">
{groups.map(({ planId, head: p, active, versions }) => { {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"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-semibold text-term-text">{p.name}</span> <span className="font-semibold text-term-text">{p.name}</span>
{active ? ( {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> </div>
{/* Details: price · hours · effective */} {/* 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"> <span className="tabular-nums text-term-text">
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])} {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</span> </span>
@@ -280,7 +280,7 @@ export function SubscriptionPlansManager() {
</div> </div>
{/* Used by */} {/* Used by */}
<div className="mt-1 text-[12px]"> <div className="mt-1 text-[0.75rem]">
{users.length > 0 ? ( {users.length > 0 ? (
<button <button
type="button" type="button"
@@ -295,12 +295,12 @@ export function SubscriptionPlansManager() {
)} )}
</div> </div>
{isOpen && users.length > 0 && ( {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) => ( {users.map((s) => (
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}> <li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
{s.holderName || t("subs.unnamed")} {s.holderName || t("subs.unnamed")}
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>} {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> </li>
))} ))}
</ul> </ul>
@@ -349,14 +349,14 @@ export function SubscriptionPlansManager() {
<span className="flex items-center gap-2"> <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-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 })} /> <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> </span>
</div> </div>
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the {/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
window they're charged the transient tariff for the gap. Off = 24/7. */} window they're charged the transient tariff for the gap. Off = 24/7. */}
<div className="mt-3 border-t border-term-border pt-3"> <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 <input
type="checkbox" type="checkbox"
className="accent-term-amber" className="accent-term-amber"
@@ -370,7 +370,7 @@ export function SubscriptionPlansManager() {
<label className="label">{t("plans.days")}</label> <label className="label">{t("plans.days")}</label>
<span className="flex flex-wrap gap-2"> <span className="flex flex-wrap gap-2">
{DOW_ORDER.map((d) => ( {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 <input
type="checkbox" type="checkbox"
className="accent-term-amber" className="accent-term-amber"
@@ -386,7 +386,7 @@ export function SubscriptionPlansManager() {
))} ))}
</span> </span>
<label className="label">{t("plans.window")}</label> <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")} {t("plans.enterAfter")}
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} /> <input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
{t("plans.exitBefore")} {t("plans.exitBefore")}
@@ -395,14 +395,14 @@ export function SubscriptionPlansManager() {
<label className="label">{t("plans.grace")}</label> <label className="label">{t("plans.grace")}</label>
<span className="flex items-center gap-2"> <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 })} /> <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> </span>
</div> </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> </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"> <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-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</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"> <section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2> <h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
{!state?.active ? ( {!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")} {t("tariff.noRateCard")}
</p> </p>
) : ( ) : (
<p className="mb-4 text-[12px] text-term-muted"> <p className="mb-4 text-[0.75rem] text-term-muted">
{t("tariff.activeSince", { {t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(), date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length, count: state.versions.length,
@@ -407,7 +407,7 @@ export function TariffComposer() {
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the {/* 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. */} engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
{form.base.mode === "stepped" && form.tiers.length > 0 && ( {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")} {t("tariff.steppedTiersConflict")}
</p> </p>
)} )}
@@ -432,7 +432,7 @@ export function TariffComposer() {
<label className="label">{t("tariff.tierDays")}</label> <label className="label">{t("tariff.tierDays")}</label>
<span className="flex flex-wrap gap-2"> <span className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 0].map((d) => ( {[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)} /> <input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)} {t(`tariff.dow${d}`)}
</label> </label>
@@ -444,7 +444,7 @@ export function TariffComposer() {
<span className="text-term-muted">–</span> <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" /> <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 && ( {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> </span>
<label className="label">{t("tariff.tierDates")}</label> <label className="label">{t("tariff.tierDates")}</label>
@@ -478,7 +478,7 @@ export function TariffComposer() {
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")} {saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button> </button>
{msg && ( {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> </div>
</section> </section>
@@ -504,7 +504,7 @@ function PricingEditor(props: {
const { t, pricing: p } = props; const { t, pricing: p } = props;
return ( return (
<div> <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"> <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")} /> <input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")} {t("tariff.modeLadder")}
@@ -538,7 +538,7 @@ function PricingEditor(props: {
<td className="px-2 py-1"> <td className="px-2 py-1">
<span className="inline-flex items-center gap-2"> <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")} /> <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> </span>
</td> </td>
<td className="px-2 py-1"> <td className="px-2 py-1">
@@ -587,7 +587,7 @@ function PricingEditor(props: {
) : ( ) : (
<span className="inline-flex items-center gap-2"> <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")} /> <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> </span>
)} )}
</td> </td>
+7 -7
View File
@@ -131,7 +131,7 @@ export function TariffLab() {
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}> <button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
{t("lab.load")} {t("lab.load")}
</button> </button>
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>} {loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
</div> </div>
{/* Hypothetical session inputs */} {/* Hypothetical session inputs */}
@@ -167,7 +167,7 @@ export function TariffLab() {
<label className="label">{t("lab.payment")}</label> <label className="label">{t("lab.payment")}</label>
<span className="flex flex-wrap items-center gap-2"> <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)} /> <input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
{t("lab.paid")} {t("lab.paid")}
</label> </label>
@@ -190,7 +190,7 @@ export function TariffLab() {
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}> <button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")} {busy ? t("lab.pricing") : t("lab.price")}
</button> </button>
{err && <span className="text-[12px] text-term-red">{err}</span>} {err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</div> </div>
{result && ( {result && (
@@ -198,19 +198,19 @@ export function TariffLab() {
{/* Outcome */} {/* Outcome */}
<div className="card card-body"> <div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3> <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> <dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd> <dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt> <dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text"> <dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))} {formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
{result.pricing.overstay && ( {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")} {t("lab.overstay")}
</span> </span>
)} )}
{result.pricing.withinGrace && ( {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")} {t("lab.settled")}
</span> </span>
)} )}
@@ -230,7 +230,7 @@ export function TariffLab() {
<div className="card card-body"> <div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3> <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> <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> <tbody>
{result.curve.map((c) => ( {result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40"> <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> </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"> <Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
<UserForm <UserForm
@@ -100,8 +100,8 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
</Modal> </Modal>
<div className="overflow-hidden rounded-term border border-term-border"> <div className="overflow-hidden rounded-term border border-term-border">
<table className="w-full text-[12px]"> <table className="w-full text-[0.75rem]">
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted"> <thead className="bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr> <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.username")}</th>
<th className="px-3 py-1.5 text-left">{t("users.role")}</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>} {!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
{/* Optional profile metadata. */} {/* 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="grid grid-cols-2 gap-3">
<div className="field"> <div className="field">
<span className="label">{t("users.fullName")}</span> <span className="label">{t("users.fullName")}</span>
+8 -8
View File
@@ -180,7 +180,7 @@ body {
background: var(--color-term-bg); background: var(--color-term-bg);
color: var(--color-term-text); color: var(--color-term-text);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 13px; font-size: 0.8125rem;
line-height: 1.4; line-height: 1.4;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
/* Crisp text and no rubber-banding on the fixed appliance display. */ /* Crisp text and no rubber-banding on the fixed appliance display. */
@@ -266,23 +266,23 @@ body {
/* Small / dense variant for inline table cells */ /* Small / dense variant for inline table cells */
.input-sm { .input-sm {
height: var(--control-h-sm); height: var(--control-h-sm);
@apply px-2 text-[12px]; @apply px-2 text-[0.75rem];
} }
.field { .field {
@apply flex flex-col gap-1; @apply flex flex-col gap-1;
} }
.label { .label {
@apply text-[11px] uppercase tracking-wider text-term-muted; @apply text-[0.6875rem] uppercase tracking-wider text-term-muted;
} }
.hint { .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 ---- */ /* ---- Buttons: a button must look pressable, never like a field ---- */
.btn { .btn {
@apply inline-flex items-center justify-center gap-1.5 rounded-term border @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; transition-colors select-none;
height: var(--control-h-md); height: var(--control-h-md);
/* Neutral default: a filled grey body, not a bare outline. */ /* Neutral default: a filled grey body, not a bare outline. */
@@ -302,11 +302,11 @@ body {
} }
.btn-sm { .btn-sm {
height: var(--control-h-sm); height: var(--control-h-sm);
@apply px-2.5 text-[11px]; @apply px-2.5 text-[0.6875rem];
} }
.btn-lg { .btn-lg {
height: var(--control-h-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. */ /* Primary: FILLED amber, dark text — the unmistakable main action. */
@@ -366,7 +366,7 @@ body {
} }
.card-head { .card-head {
@apply flex items-center justify-between border-b border-term-border @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 { .card-body {
@apply p-4; @apply p-4;
+8 -11
View File
@@ -121,7 +121,7 @@ export const en: Catalog = {
scanPlaceholder: "Scan or type ticket number…", scanPlaceholder: "Scan or type ticket number…",
laneEntry: "Entry", laneEntry: "Entry",
laneExit: "Exit", laneExit: "Exit",
open: "Open", openTicket: "Read",
occupancy: "Occupancy", occupancy: "Occupancy",
occUnavailable: "occupancy unavailable", occUnavailable: "occupancy unavailable",
inside: "inside", inside: "inside",
@@ -136,6 +136,11 @@ export const en: Catalog = {
insideCount: "inside", insideCount: "inside",
noActiveSessions: "No active sessions.", noActiveSessions: "No active sessions.",
noMatch: "No sessions match the filter.", noMatch: "No sessions match the filter.",
// Active-sessions table column headers.
colWho: "Ticket / subscriber",
colPlate: "Plate",
colEntry: "Entry",
colElapsed: "Elapsed",
badgeOverstay: "overstay", badgeOverstay: "overstay",
badgeOverstayTitle: badgeOverstayTitle:
"Paid session. The customer failed to exit during the grace period. A new period began.", "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…", filterSearchSessions: "Search ticket / subscriber / plate…",
filterSearchFeed: "Search event / identity / plate…", filterSearchFeed: "Search event / identity / plate…",
filterAll: "All", filterAll: "All",
fStatusUnpaid: "Unpaid",
fStatusPaid: "Paid",
fStatusExiting: "Exiting",
fStatusOverstay: "Overstay",
fKindTransient: "Transient", fKindTransient: "Transient",
fKindSubscription: "Subscribers", fKindSubscription: "Subscribers",
fDirEntry: "Entry",
fDirExit: "Exit",
fSrcBooth: "Booth", fSrcBooth: "Booth",
fSrcReader: "Reader", fSrcReader: "Reader",
fEvtEntry: "Entry", fEvtEntry: "Entry",
@@ -702,10 +701,9 @@ export const en: Catalog = {
card: "Card:", card: "Card:",
srcTickets: "Tickets:", srcTickets: "Tickets:",
srcSubscriptions: "Subscriptions:", srcSubscriptions: "Subscriptions:",
srcSubSales: "sales",
srcSubWindow: "out-of-window", srcSubWindow: "out-of-window",
drawerSection: "— Drawer —", drawerSection: "— Drawer —",
openingFloat: "Opening float:", openingFloat: "Opening cash:",
cashTaken: "Cash taken:", cashTaken: "Cash taken:",
cashAdded: "Cash added:", cashAdded: "Cash added:",
cashRemoved: "Cash removed:", cashRemoved: "Cash removed:",
@@ -739,7 +737,6 @@ export const en: Catalog = {
card: "Card", card: "Card",
srcTickets: "Tickets", srcTickets: "Tickets",
srcSubscriptions: "Subscriptions", srcSubscriptions: "Subscriptions",
srcSubSales: "subs sales",
srcSubWindow: "out-of-window", srcSubWindow: "out-of-window",
expectedDrawer: "Expected drawer", expectedDrawer: "Expected drawer",
filterFrom: "From", filterFrom: "From",
@@ -757,7 +754,7 @@ export const en: Catalog = {
noActivity: "No activity in this shift.", noActivity: "No activity in this shift.",
current: "current", current: "current",
drawerSection: "Drawer", drawerSection: "Drawer",
openingFloat: "Opening float", openingFloat: "Opening cash",
cashTaken: "Cash taken", cashTaken: "Cash taken",
cashAdded: "Cash added", cashAdded: "Cash added",
cashRemoved: "Cash removed", cashRemoved: "Cash removed",
+9 -12
View File
@@ -123,7 +123,7 @@ export const sq = {
scanPlaceholder: "Skano ose shkruaj numrin e biletës…", scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
laneEntry: "Hyrje", laneEntry: "Hyrje",
laneExit: "Dalje", laneExit: "Dalje",
open: "Hap", openTicket: "Lexo",
occupancy: "Prania", occupancy: "Prania",
occUnavailable: "zënia e padisponueshme", occUnavailable: "zënia e padisponueshme",
inside: "brenda", inside: "brenda",
@@ -138,6 +138,11 @@ export const sq = {
insideCount: "brenda", insideCount: "brenda",
noActiveSessions: "Asnjë sesion aktiv.", noActiveSessions: "Asnjë sesion aktiv.",
noMatch: "Asnjë rezultat për filtrin.", 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", badgeOverstay: "tej afatit",
badgeOverstayTitle: badgeOverstayTitle:
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.", "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ë…", filterSearchSessions: "Kërko biletë / abonent / targë…",
filterSearchFeed: "Kërko event / identitet / targë…", filterSearchFeed: "Kërko event / identitet / targë…",
filterAll: "Të gjitha", filterAll: "Të gjitha",
fStatusUnpaid: "Papaguar",
fStatusPaid: "Paguar",
fStatusExiting: "Duke dalë",
fStatusOverstay: "Tej afatit",
fKindTransient: "Kalimtarë", fKindTransient: "Kalimtarë",
fKindSubscription: "Abonentë", fKindSubscription: "Abonentë",
fDirEntry: "Hyrje",
fDirExit: "Dalje",
fSrcBooth: "Kabinë", fSrcBooth: "Kabinë",
fSrcReader: "Lexues", fSrcReader: "Lexues",
fEvtEntry: "Hyrje", fEvtEntry: "Hyrje",
@@ -687,7 +686,7 @@ export const sq = {
starting: "Duke filluar…", starting: "Duke filluar…",
endShift: "Mbyll turnin", endShift: "Mbyll turnin",
ending: "Duke mbyllur…", ending: "Duke mbyllur…",
endConfirm: "Të mbyllet ky turn? Regjistrohet dhe printohet një Raport Z i nënshkruar.", endConfirm: "",
drawer: "Arka:", drawer: "Arka:",
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)", openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin", drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
@@ -715,10 +714,9 @@ export const sq = {
card: "Kartë:", card: "Kartë:",
srcTickets: "Bileta:", srcTickets: "Bileta:",
srcSubscriptions: "Abonime:", srcSubscriptions: "Abonime:",
srcSubSales: "shitje",
srcSubWindow: "jashtë orarit", srcSubWindow: "jashtë orarit",
drawerSection: "— Arka —", drawerSection: "— Arka —",
openingFloat: "Bilanci fillestar:", openingFloat: "Arka fillestare:",
cashTaken: "Para të marra:", cashTaken: "Para të marra:",
cashAdded: "Para të shtuara:", cashAdded: "Para të shtuara:",
cashRemoved: "Para të hequra:", cashRemoved: "Para të hequra:",
@@ -752,7 +750,6 @@ export const sq = {
card: "Kartë", card: "Kartë",
srcTickets: "Bileta", srcTickets: "Bileta",
srcSubscriptions: "Abonime", srcSubscriptions: "Abonime",
srcSubSales: "shitje abonimesh",
srcSubWindow: "jashtë orarit", srcSubWindow: "jashtë orarit",
expectedDrawer: "Gjëndje arke", expectedDrawer: "Gjëndje arke",
// Filter (admin only). // Filter (admin only).
@@ -772,7 +769,7 @@ export const sq = {
current: "aktual", current: "aktual",
// Expanded drawer detail. // Expanded drawer detail.
drawerSection: "Arka", drawerSection: "Arka",
openingFloat: "Bilanci fillestar", openingFloat: "Arka fillestare",
cashTaken: "Para të marra", cashTaken: "Para të marra",
cashAdded: "Para të shtuara", cashAdded: "Para të shtuara",
cashRemoved: "Para të hequra", 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"); 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 /** Apply a font scale by setting the ROOT font-size (percent). The app's text is sized in
* px (`text-[12px]` etc.), which a root font-size would NOT scale — `zoom` scales everything * rem (the `text-[…rem]` utilities + the .label/.input/.hint/.btn component classes all
* uniformly (text, spacing, icons), exactly like the browser's Ctrl+/−, so the feed/session * derive from the root), so only TEXT scales — viewport-locked layout (h-screen frame,
* logs grow too. Clamped to the allowed band; no-op-safe to call repeatedly. */ * 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 { export function applyFontScale(pct: number): void {
const clamped = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(pct))); 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). // 100% = the browser's 16px root. The app's rem units scale off this.
// 1 = 100%. Reset to "" at base so we don't leave an inline override lying around. document.documentElement.style.fontSize = clamped === 100 ? "" : `${clamped}%`;
document.documentElement.style.zoom = clamped === 100 ? "" : String(clamped / 100);
} }
+21 -13
View File
@@ -68,7 +68,7 @@ function NavLink({ to, label }: { to: string; label: string }) {
return ( return (
<Link <Link
to={to} 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} {label}
</Link> </Link>
@@ -82,7 +82,7 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
<Link <Link
to={to} to={to}
activeOptions={{ exact }} 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} {label}
</Link> </Link>
@@ -159,7 +159,7 @@ function LanguageToggle({
} }
} }
return ( 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) => ( {(["sq", "en"] as const).map((l) => (
<button <button
key={l} key={l}
@@ -205,7 +205,7 @@ function ThemeToggle({
} }
} }
return ( 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) => ( {(["dark", "light"] as const).map((th) => (
<button <button
key={th} 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"; const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
return ( 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")}> <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− A−
</button> </button>
@@ -316,14 +316,14 @@ function ShiftButton() {
disabled={busy || blockedByOther} disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined} title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={onClick} 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} {busy ? t("shift.opening") : label}
</button> </button>
{!isOpen && ( {!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 && ( {confirmingClose && (
<CloseShiftConfirm <CloseShiftConfirm
busy={busy} busy={busy}
@@ -358,7 +358,7 @@ function CloseShiftConfirm({
return ( return (
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md"> <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> <p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? ( {!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p> <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. */} {/* 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.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} /> <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 /> <ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2"> <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.cash")} value={fmt(x.cashTotalMinor)} />
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} /> <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 /> <ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div> </div>
</> </>
@@ -396,10 +401,13 @@ function CloseShiftConfirm({
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return ( return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}> <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} {label}
</span> </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> </div>
); );
} }
@@ -448,7 +456,7 @@ function RootLayout() {
<Link <Link
to="/profile" to="/profile"
title={t("nav.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} {user.username} · {user.roleName}
</Link> </Link>
+6 -6
View File
@@ -98,7 +98,7 @@ export function DeviceFooter() {
return ( return (
<footer <footer
ref={rootRef} 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"> <span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
{t("devices.footerTitle")} {t("devices.footerTitle")}
@@ -156,7 +156,7 @@ export function DeviceFooter() {
{open && problems.length > 0 && ( {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="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"> <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")} {t("devices.issuesTitle")}
</span> </span>
<button <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]}`} /> <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="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span> <span className={`text-[0.75rem] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted"> <span className="shrink-0 text-[0.625rem] uppercase tracking-wider text-term-muted">
{t(`devices.state.${d.state}`)} {t(`devices.state.${d.state}`)}
</span> </span>
</div> </div>
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>} {d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70"> <div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })} {t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
</div> </div>
</div> </div>
+2 -2
View File
@@ -27,7 +27,7 @@ export function SegGroup<V extends string>({
key={v || "all"} key={v || "all"}
type="button" type="button"
onClick={() => onChange(v)} 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" value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
}`} }`}
> >
@@ -61,7 +61,7 @@ export function FilterBar({
value={search} value={search}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
placeholder={searchPlaceholder} 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} {children}
</div> </div>
+1 -1
View File
@@ -30,7 +30,7 @@ export function Modal({
aria-describedby={undefined} 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"> <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} {title}
</Dialog.Title> </Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close"> <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 && ( {title && (
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border"> <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} {title}
</h2> </h2>
{right} {right}
+8 -8
View File
@@ -37,9 +37,9 @@ export function SnapshotStrip({ identity }: { identity: string }) {
const dirLabel = (dir: "entry" | "exit" | null): string => const dirLabel = (dir: "entry" | "exit" | null): string =>
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—"; 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) 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 ( return (
<> <>
@@ -50,15 +50,15 @@ export function SnapshotStrip({ identity }: { identity: string }) {
{dedupePlates(plates).map((p, i) => ( {dedupePlates(plates).map((p, i) => (
<span <span
key={`${p.plate}-${p.direction}-${i}`} 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}` : ""}${ title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
p.at ? ` · ${new Date(p.at).toLocaleString()}` : "" 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> <span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
{typeof p.confidence === "number" && ( {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> </span>
))} ))}
@@ -81,7 +81,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
loading="lazy" loading="lazy"
/> />
<span <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" 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()}` : ""}`} 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-lg leading-none text-term-amber">⚠</span>
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span> <span className="text-[0.5625rem] 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="px-1 text-[0.5625rem] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
</div> </div>
))} ))}
</div> </div>
+1 -1
View File
@@ -19,7 +19,7 @@ export function StatusDot() {
const { t } = useTranslation(); const { t } = useTranslation();
const status = useLiveStore((s) => s.status); const status = useLiveStore((s) => s.status);
return ( 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 <span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`} className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/> />
+31 -39
View File
@@ -95,9 +95,9 @@ export function displayIdentity(e: LedgerEvent): string {
return e.subscriberLabel ?? e.identity ?? "—"; return e.subscriberLabel ?? e.identity ?? "—";
} }
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps /** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the
* the time/label/identity/index columns aligned across rows; the detail line lives in * time/label/#index columns aligned across rows; the identity, plate, badges and reason flow
* its own row, indented under the identity column. */ * 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 }) { export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const style = eventStyleFor(e); const style = eventStyleFor(e);
@@ -110,52 +110,44 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
const reason = renderReason(p, t); const reason = renderReason(p, t);
const amount = paymentSummary(p); const amount = paymentSummary(p);
const badges = eventBadges(p); const badges = eventBadges(p);
const via = viaKey(p);
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null); const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
const showDetail = detail != null || badges.length > 0 || via != null;
return ( return (
<button <button
type="button" type="button"
onClick={() => onOpen(e)} 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" : "" isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
}`} }`}
> >
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span> <span className="py-px text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span> <span className={`py-px shrink-0 font-semibold ${style.color}`}>{label}</span>
<span className="flex min-w-0 items-center gap-2"> {/* Identity + plate + detail all flow in ONE wrapping line — they fill the available
<span className="truncate text-term-text">{displayIdentity(e)}</span> 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 && ( {e.plate && (
<span <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")} title={t("booth.plateTitle")}
> >
{e.plate} {e.plate}
</span> </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) => ( {badges.map((k) => (
<span <span
key={k} 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="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)} {t(k)}
</span> </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 && ( {detail && (
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span> <span className={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
</div>
)} )}
</span>
<span className="py-px text-term-muted">#{e.index}</span>
</button> </button>
); );
} }
@@ -163,8 +155,8 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
/** One label/value line in the event-detail modal. */ /** One label/value line in the event-detail modal. */
function DetailRow({ label, children }: { label: string; children: ReactNode }) { function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return ( return (
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]"> <div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[0.75rem]">
<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="min-w-0 break-words text-term-text">{children}</span> <span className="min-w-0 break-words text-term-text">{children}</span>
</div> </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={`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> <div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
{(reason || money) && ( {(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} {reason ?? money}
</div> </div>
)} )}
{!reason && !money && isAnomaly && ( {!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 && ( {badges.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{badges.map((k) => ( {badges.map((k) => (
<span <span
key={k} 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)} {t(k)}
</span> </span>
@@ -235,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
(the SUBSESS-… session key) for traceability against the ledger. */} (the SUBSESS-… session key) for traceability against the ledger. */}
{e.subscriberLabel && e.identity && ( {e.subscriberLabel && e.identity && (
<DetailRow label={t("booth.edOccurrence")}> <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> </DetailRow>
)} )}
{money && ( {money && (
@@ -257,7 +249,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
)} )}
{tariffVersionId && ( {tariffVersionId && (
<DetailRow label={t("booth.edTariffVersion")}> <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> </DetailRow>
)} )}
</div> </div>
@@ -265,7 +257,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
{/* The entry/exit evidence images for this session's identity. */} {/* The entry/exit evidence images for this session's identity. */}
{e.identity && ( {e.identity && (
<div> <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} /> <SnapshotStrip identity={e.identity} />
</div> </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 operator's; tucking them behind a disclosure keeps the common view clean
while preserving the tamper-evidence trail on demand. */} while preserving the tamper-evidence trail on demand. */}
<details className="rounded-term border border-term-border bg-term-panel-2"> <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")} {t("booth.edAuditData")}
</summary> </summary>
<div className="border-t border-term-border px-3 pb-3 pt-1"> <div className="border-t border-term-border px-3 pb-3 pt-1">
<DetailRow label={t("booth.edSignature")}> <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>
<DetailRow label={t("booth.edKeyId")}> <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>
<DetailRow label={t("booth.edPrevHash")}> <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> </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")} {t("booth.edRawPayload")}
</div> </div>
{p && Object.keys(p).length > 0 ? ( {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)} {JSON.stringify(p, null, 2)}
</pre> </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> </div>
</details> </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, 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/ 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). 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.