diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index ecf6851..83df760 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -548,8 +548,9 @@ export class ShiftService { "", "-- Arkëtime sipas burimit --", `Bileta: ${money(r.ticketTotalMinor)} ${cur}`, + // Abonime is the subscription TOTAL; only the out-of-window part is broken out. + // (subscriptionSalesMinor stays in the signed payload — it's just not printed.) `Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`, - ` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`, ` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`, "", "-- Arka --", diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index 0ad8360..8621c49 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -26,26 +26,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; // reconciles via the pay/exit modal — never a free barrier open. // See wiki/concepts/booth-exit-flow.md. -type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay"; type KindFilter = "transient" | "subscription"; -function statusOf(s: ActiveSession): StatusFilter | "subscription" { - if (s.subscription) return "subscription"; - if (s.overstay) return "overstay"; - if (!s.open && s.withinGrace) return "exiting"; - if (s.paidAt) return "paid"; - return "unpaid"; -} - -function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } { - if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" }; - if (s.overstay) - return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" }; - if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" }; - if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" }; - return { key: "booth.badgeUnpaid", cls: "text-term-amber" }; -} - export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -70,9 +52,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }); const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null); - // Filters: free-text search, status, and transient-vs-subscriber. + // Filters: free-text search + transient-vs-subscriber. (No status filter — the status + // column was dropped; an unpaid transient is normal and a subscriber is marked ★.) const [search, setSearch] = useState(""); - const [status, setStatus] = useState(""); const [kind, setKind] = useState(""); const sessions = useMemo(() => data?.sessions ?? [], [data]); @@ -81,7 +63,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void return sessions.filter((s) => { if (kind === "transient" && s.subscription) return false; if (kind === "subscription" && !s.subscription) return false; - if (status && statusOf(s) !== status) return false; if (q) { // Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits. const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase(); @@ -89,14 +70,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void } return true; }); - }, [sessions, search, status, kind]); + }, [sessions, search, kind]); - const statusOpts: SegOption[] = [ - { 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[] = [ { value: "transient", label: t("booth.fKindTransient") }, { value: "subscription", label: t("booth.fKindSubscription") }, @@ -120,7 +95,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void + {filtered.length} {filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")} @@ -129,7 +104,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void >
- @@ -143,70 +117,87 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void : t("booth.noMatch")}
) : ( - filtered.map((s) => { - const badge = statusBadge(s); - const msg = reopenMsg?.id === s.identity ? reopenMsg : null; - return ( -
- - - {/* 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 ? ( - - ) : ( - - )} - - {msg && ( - - {msg.text} - - )} -
- ); - }) + + {s.subscription ? ( + ★ {s.subscriptionHolder ?? t("subs.unnamed")} + ) : ( + s.identity + )} + + + {s.plate && ( + + {s.plate} + + )} + + + {formatRelativeDateTime(s.enteredAt, t)} + + + {formatDuration(s.enteredAt, new Date().toISOString())} + + + {canReopen && ( + + )} + {msg && ( + + {msg.text} + + )} + + + ); + })} + + )} diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 249c7c1..34a658b 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -226,7 +226,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose aria-describedby={undefined} >
- + {isSubscription ? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}` : `${t("pay.ticket")} ${identity}`} @@ -244,19 +244,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{blockedByOther ? ( <> -
+
{t("shift.gateOtherTitle")}
-
+
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
) : ( <> -
+
{t("shift.gateTitle")}
-
{t("shift.gateBody")}
+
{t("shift.gateBody")}
); diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index a0b0d49..b43e139 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -49,13 +49,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
{occ.count}
-
{t("booth.inside")}
+
{t("booth.inside")}
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
-
{t("booth.free")}
+
{t("booth.free")}
{occ.free == null ? "∞" : occ.free}
@@ -67,7 +67,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
)} {occ.full && ( -
+
{t("booth.lotFull")}
)} @@ -106,7 +106,7 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) { className="input h-11 flex-1 px-3 text-lg tabular-nums" /> ); @@ -134,7 +134,7 @@ function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; ra
-
{label}
+
{label}
{busy ? "●" : blinking ? "◐" : "○"}
@@ -181,10 +181,10 @@ export function BoothScreen() { // abandon an in-progress payment (the operator finishes/closes, then scans the next). useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null }); - // Live-feed filters: free-text search, event category, and direction/source. + // Live-feed filters: free-text search, event type, and source. (No direction filter — + // HYRJE/DALJE there just duplicated the entry/exit options already in the Type filter.) const [feedSearch, setFeedSearch] = useState(""); const [feedType, setFeedType] = useState(""); - const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">(""); const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">(""); // Live overlays from the WS store. @@ -212,7 +212,6 @@ export function BoothScreen() { const fq = feedSearch.trim().toLowerCase(); const events = scoped.filter((e) => { if (feedType && feedCat(e.type) !== feedType) return false; - if (feedDir && e.direction !== feedDir) return false; if (feedSrc) { const isBooth = e.source === "manual"; if (feedSrc === "booth" ? !isBooth : isBooth) return false; @@ -231,10 +230,6 @@ export function BoothScreen() { { value: "void", label: t("booth.fEvtVoid") }, { value: "anomaly", label: t("booth.fEvtAnomaly") }, ]; - const feedDirOpts: SegOption<"entry" | "exit">[] = [ - { value: "entry", label: t("booth.fDirEntry") }, - { value: "exit", label: t("booth.fDirExit") }, - ]; const feedSrcOpts: SegOption<"booth" | "reader">[] = [ { value: "booth", label: t("booth.fSrcBooth") }, { value: "reader", label: t("booth.fSrcReader") }, @@ -272,7 +267,7 @@ export function BoothScreen() { + {events.length} {events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")} @@ -288,7 +283,6 @@ export function BoothScreen() { onChange={setFeedType} allLabel={t("booth.filterAll")} /> - )} diff --git a/apps/web/src/Login.tsx b/apps/web/src/Login.tsx index ae630ee..7526a29 100644 --- a/apps/web/src/Login.tsx +++ b/apps/web/src/Login.tsx @@ -46,7 +46,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) autoComplete="current-password" />
- {error &&

{error}

} + {error &&

{error}

} diff --git a/apps/web/src/LogsViewer.tsx b/apps/web/src/LogsViewer.tsx index 4fe903d..0159b9b 100644 --- a/apps/web/src/LogsViewer.tsx +++ b/apps/web/src/LogsViewer.tsx @@ -31,7 +31,7 @@ function LogRow({ log }: { log: AppLogRecord }) { - {accountMsg && {accountMsg}} + {accountMsg && {accountMsg}}
@@ -124,7 +124,7 @@ export function Profile({

{t("profile.passwordSection")}

-
diff --git a/apps/web/src/RecycleBin.tsx b/apps/web/src/RecycleBin.tsx index 38f894f..b92be2a 100644 --- a/apps/web/src/RecycleBin.tsx +++ b/apps/web/src/RecycleBin.tsx @@ -76,13 +76,13 @@ export function RecycleBin({ user }: { user: SessionUser | null }) { {t("recycleBin.title")} {retentionDays > 0 && ( - + {t("recycleBin.retentionNote", { days: retentionDays })} )}
- {error &&

{error}

} + {error &&

{error}

} {binQ.isLoading &&

{t("common.loading")}

} {!binQ.isLoading && items.length === 0 ? ( @@ -90,9 +90,9 @@ export function RecycleBin({ user }: { user: SessionUser | null }) { {t("recycleBin.empty")}

) : ( - +
- + @@ -103,7 +103,7 @@ export function RecycleBin({ user }: { user: SessionUser | null }) { {items.map((it) => ( @@ -146,10 +146,10 @@ export function RecycleBin({ user }: { user: SessionUser | null }) { {purging && ( setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}> -

+

{t("recycleBin.purgeConfirmBody", { label: purging.label })}

-

{t("recycleBin.purgeIrreversible")}

+

{t("recycleBin.purgeIrreversible")}

))}
-
+
{t("reports.groupBy")} toggle(p)} /> {action} diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index d154102..d530c6f 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -206,8 +206,8 @@ function CategorySection({ {warnings.length > 0 && (
- {t("setup.warnTitle")} -
    + {t("setup.warnTitle")} +
      {warnings.map((w, i) => (
    • {w}
    • ))} @@ -233,7 +233,7 @@ function CategorySection({ )} {blockedNoController ? ( -

      {t("setup.needControllerFirst", { noun })}

      +

      {t("setup.needControllerFirst", { noun })}

      ) : ( - {scanError && {scanError}} - {found && found.length === 0 &&

      {t("setup.noControllersFound")}

      } + {scanError && {scanError}} + {found && found.length === 0 &&

      {t("setup.noControllersFound")}

      } {found && found.length > 0 && (
        {found.map((d) => ( -
      • +
      • @@ -734,7 +734,7 @@ function DeviceForm({ f.type === "boolean" ? ( // Boolean config field → a real checkbox (stores a true/false boolean, not // the string "true"). The label sits beside the box, with the help below. -
diff --git a/apps/web/src/TariffLab.tsx b/apps/web/src/TariffLab.tsx index 73d4293..6db953d 100644 --- a/apps/web/src/TariffLab.tsx +++ b/apps/web/src/TariffLab.tsx @@ -131,7 +131,7 @@ export function TariffLab() { - {loadMsg && {loadMsg}} + {loadMsg && {loadMsg}} {/* Hypothetical session inputs */} @@ -167,7 +167,7 @@ export function TariffLab() { -
{t("recycleBin.col.type")} {t("recycleBin.col.item")} {t("recycleBin.col.deleted")}
- + {t(KIND_KEY[it.kind])} props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> - {t("tariff.hoursUnit")} + {t("tariff.hoursUnit")} @@ -587,7 +587,7 @@ function PricingEditor(props: { ) : ( props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> - {t("tariff.hoursUnit")} + {t("tariff.hoursUnit")} )}
+
{result.curve.map((c) => ( diff --git a/apps/web/src/UsersManager.tsx b/apps/web/src/UsersManager.tsx index b1cb4f5..f7facf8 100644 --- a/apps/web/src/UsersManager.tsx +++ b/apps/web/src/UsersManager.tsx @@ -51,7 +51,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) { )} - {error &&
{error}
} + {error &&
{error}
} setAdding(false)} title={t("users.new")} width="max-w-2xl">
-
- +
+ @@ -291,7 +291,7 @@ function UserForm({ {!isEdit &&
{t("users.passwordHint")}
} {/* Optional profile metadata. */} -
{t("users.detailsSection")}
+
{t("users.detailsSection")}
{t("users.fullName")} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4488103..b2328fb 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -180,7 +180,7 @@ body { background: var(--color-term-bg); color: var(--color-term-text); font-family: var(--font-mono); - font-size: 13px; + font-size: 0.8125rem; line-height: 1.4; -webkit-font-smoothing: antialiased; /* Crisp text and no rubber-banding on the fixed appliance display. */ @@ -266,23 +266,23 @@ body { /* Small / dense variant for inline table cells */ .input-sm { height: var(--control-h-sm); - @apply px-2 text-[12px]; + @apply px-2 text-[0.75rem]; } .field { @apply flex flex-col gap-1; } .label { - @apply text-[11px] uppercase tracking-wider text-term-muted; + @apply text-[0.6875rem] uppercase tracking-wider text-term-muted; } .hint { - @apply text-[11px] leading-snug text-term-muted; + @apply text-[0.6875rem] leading-snug text-term-muted; } /* ---- Buttons: a button must look pressable, never like a field ---- */ .btn { @apply inline-flex items-center justify-center gap-1.5 rounded-term border - px-3 text-[12px] font-semibold uppercase tracking-wider + px-3 text-[0.75rem] font-semibold uppercase tracking-wider transition-colors select-none; height: var(--control-h-md); /* Neutral default: a filled grey body, not a bare outline. */ @@ -302,11 +302,11 @@ body { } .btn-sm { height: var(--control-h-sm); - @apply px-2.5 text-[11px]; + @apply px-2.5 text-[0.6875rem]; } .btn-lg { height: var(--control-h-lg); - @apply px-5 text-[13px]; + @apply px-5 text-[0.8125rem]; } /* Primary: FILLED amber, dark text — the unmistakable main action. */ @@ -366,7 +366,7 @@ body { } .card-head { @apply flex items-center justify-between border-b border-term-border - bg-term-panel-2 px-4 py-2 text-[12px] uppercase tracking-wider text-term-muted; + bg-term-panel-2 px-4 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted; } .card-body { @apply p-4; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index b68d06f..d7f8211 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -121,7 +121,7 @@ export const en: Catalog = { scanPlaceholder: "Scan or type ticket number…", laneEntry: "Entry", laneExit: "Exit", - open: "Open", + openTicket: "Read", occupancy: "Occupancy", occUnavailable: "occupancy unavailable", inside: "inside", @@ -136,6 +136,11 @@ export const en: Catalog = { insideCount: "inside", noActiveSessions: "No active sessions.", noMatch: "No sessions match the filter.", + // Active-sessions table column headers. + colWho: "Ticket / subscriber", + colPlate: "Plate", + colEntry: "Entry", + colElapsed: "Elapsed", badgeOverstay: "overstay", badgeOverstayTitle: "Paid session. The customer failed to exit during the grace period. A new period began.", @@ -144,14 +149,8 @@ export const en: Catalog = { filterSearchSessions: "Search ticket / subscriber / plate…", filterSearchFeed: "Search event / identity / plate…", filterAll: "All", - fStatusUnpaid: "Unpaid", - fStatusPaid: "Paid", - fStatusExiting: "Exiting", - fStatusOverstay: "Overstay", fKindTransient: "Transient", fKindSubscription: "Subscribers", - fDirEntry: "Entry", - fDirExit: "Exit", fSrcBooth: "Booth", fSrcReader: "Reader", fEvtEntry: "Entry", @@ -702,10 +701,9 @@ export const en: Catalog = { card: "Card:", srcTickets: "Tickets:", srcSubscriptions: "Subscriptions:", - srcSubSales: "sales", srcSubWindow: "out-of-window", drawerSection: "— Drawer —", - openingFloat: "Opening float:", + openingFloat: "Opening cash:", cashTaken: "Cash taken:", cashAdded: "Cash added:", cashRemoved: "Cash removed:", @@ -739,7 +737,6 @@ export const en: Catalog = { card: "Card", srcTickets: "Tickets", srcSubscriptions: "Subscriptions", - srcSubSales: "subs sales", srcSubWindow: "out-of-window", expectedDrawer: "Expected drawer", filterFrom: "From", @@ -757,7 +754,7 @@ export const en: Catalog = { noActivity: "No activity in this shift.", current: "current", drawerSection: "Drawer", - openingFloat: "Opening float", + openingFloat: "Opening cash", cashTaken: "Cash taken", cashAdded: "Cash added", cashRemoved: "Cash removed", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 172b80e..8154648 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -123,7 +123,7 @@ export const sq = { scanPlaceholder: "Skano ose shkruaj numrin e biletës…", laneEntry: "Hyrje", laneExit: "Dalje", - open: "Hap", + openTicket: "Lexo", occupancy: "Prania", occUnavailable: "zënia e padisponueshme", inside: "brenda", @@ -138,6 +138,11 @@ export const sq = { insideCount: "brenda", noActiveSessions: "Asnjë sesion aktiv.", noMatch: "Asnjë rezultat për filtrin.", + // Kokat e kolonave të tabelës së sesioneve aktive. + colWho: "Biletë / abonent", + colPlate: "Targa", + colEntry: "Hyrja", + colElapsed: "Koha brenda", badgeOverstay: "tej afatit", badgeOverstayTitle: "Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.", @@ -146,14 +151,8 @@ export const sq = { filterSearchSessions: "Kërko biletë / abonent / targë…", filterSearchFeed: "Kërko event / identitet / targë…", filterAll: "Të gjitha", - fStatusUnpaid: "Papaguar", - fStatusPaid: "Paguar", - fStatusExiting: "Duke dalë", - fStatusOverstay: "Tej afatit", fKindTransient: "Kalimtarë", fKindSubscription: "Abonentë", - fDirEntry: "Hyrje", - fDirExit: "Dalje", fSrcBooth: "Kabinë", fSrcReader: "Lexues", fEvtEntry: "Hyrje", @@ -687,7 +686,7 @@ export const sq = { starting: "Duke filluar…", endShift: "Mbyll turnin", ending: "Duke mbyllur…", - endConfirm: "Të mbyllet ky turn? Regjistrohet dhe printohet një Raport Z i nënshkruar.", + endConfirm: "", drawer: "Arka:", openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)", drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin", @@ -715,10 +714,9 @@ export const sq = { card: "Kartë:", srcTickets: "Bileta:", srcSubscriptions: "Abonime:", - srcSubSales: "shitje", srcSubWindow: "jashtë orarit", drawerSection: "— Arka —", - openingFloat: "Bilanci fillestar:", + openingFloat: "Arka fillestare:", cashTaken: "Para të marra:", cashAdded: "Para të shtuara:", cashRemoved: "Para të hequra:", @@ -752,7 +750,6 @@ export const sq = { card: "Kartë", srcTickets: "Bileta", srcSubscriptions: "Abonime", - srcSubSales: "shitje abonimesh", srcSubWindow: "jashtë orarit", expectedDrawer: "Gjëndje arke", // Filter (admin only). @@ -772,7 +769,7 @@ export const sq = { current: "aktual", // Expanded drawer detail. drawerSection: "Arka", - openingFloat: "Bilanci fillestar", + openingFloat: "Arka fillestare", cashTaken: "Para të marra", cashAdded: "Para të shtuara", cashRemoved: "Para të hequra", diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts index 5e2997b..85a5bd6 100644 --- a/apps/web/src/lib/theme.ts +++ b/apps/web/src/lib/theme.ts @@ -13,13 +13,14 @@ export function applyTheme(theme: Theme): void { document.documentElement.classList.toggle("theme-light", theme === "light"); } -/** Apply a font scale as a whole-UI ZOOM (`pct`% on the root). The app's type is pinned in - * px (`text-[12px]` etc.), which a root font-size would NOT scale — `zoom` scales everything - * uniformly (text, spacing, icons), exactly like the browser's Ctrl+/−, so the feed/session - * logs grow too. Clamped to the allowed band; no-op-safe to call repeatedly. */ +/** Apply a font scale by setting the ROOT font-size (percent). The app's text is sized in + * rem (the `text-[…rem]` utilities + the .label/.input/.hint/.btn component classes all + * derive from the root), so only TEXT scales — viewport-locked layout (h-screen frame, + * max-h-[90vh] modals, vh units) is unaffected, so headers/footers never clip; taller + * content just scrolls its own container. NOT `zoom` (which scaled those vh boxes too and + * pushed modal chrome out of view). Clamped to the band; no-op-safe to call repeatedly. */ export function applyFontScale(pct: number): void { const clamped = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(pct))); - // `zoom` is supported in all the booth's target browsers (Chromium/WebKit/modern FF). - // 1 = 100%. Reset to "" at base so we don't leave an inline override lying around. - document.documentElement.style.zoom = clamped === 100 ? "" : String(clamped / 100); + // 100% = the browser's 16px root. The app's rem units scale off this. + document.documentElement.style.fontSize = clamped === 100 ? "" : `${clamped}%`; } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index a78d7fd..3dd5fe0 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -68,7 +68,7 @@ function NavLink({ to, label }: { to: string; label: string }) { return ( {label} @@ -82,7 +82,7 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa {label} @@ -159,7 +159,7 @@ function LanguageToggle({ } } return ( -
+
{(["sq", "en"] as const).map((l) => ( @@ -316,14 +316,14 @@ function ShiftButton() { disabled={busy || blockedByOther} title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined} onClick={onClick} - className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`} + className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`} > {busy ? t("shift.opening") : label} {!isOpen && ( - {t("shift.headerNoShift")} + {t("shift.headerNoShift")} )} - {err && {err}} + {err && {err}} {confirmingClose && ( -
+

{t("shift.endConfirm")}

{!x ? (

{t("common.loading")}

@@ -370,12 +370,17 @@ function CloseShiftConfirm({ {/* Split by source — the operator's ask: subscription money apart from tickets. */} - + {/* 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). */} +
+ {/* Drawer math made explicit: opening float + cash taken = expected drawer. */} + +
@@ -396,10 +401,13 @@ function CloseShiftConfirm({ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { return (
- + {label} - {value} + {/* The money/number never splits across lines (e.g. "89,650 ALL"). */} + {value}
); } @@ -448,7 +456,7 @@ function RootLayout() { {user.username} · {user.roleName} diff --git a/apps/web/src/ui/DeviceFooter.tsx b/apps/web/src/ui/DeviceFooter.tsx index 0a7c40f..898666e 100644 --- a/apps/web/src/ui/DeviceFooter.tsx +++ b/apps/web/src/ui/DeviceFooter.tsx @@ -98,7 +98,7 @@ export function DeviceFooter() { return (
{t("devices.footerTitle")} @@ -156,7 +156,7 @@ export function DeviceFooter() { {open && problems.length > 0 && (
- + {t("devices.issuesTitle")}
diff --git a/apps/web/src/ui/StatusDot.tsx b/apps/web/src/ui/StatusDot.tsx index 8c372f9..344a36d 100644 --- a/apps/web/src/ui/StatusDot.tsx +++ b/apps/web/src/ui/StatusDot.tsx @@ -19,7 +19,7 @@ export function StatusDot() { const { t } = useTranslation(); const status = useLiveStore((s) => s.status); return ( - + diff --git a/apps/web/src/ui/event-detail.tsx b/apps/web/src/ui/event-detail.tsx index 250e11e..2efc622 100644 --- a/apps/web/src/ui/event-detail.tsx +++ b/apps/web/src/ui/event-detail.tsx @@ -95,9 +95,9 @@ export function displayIdentity(e: LedgerEvent): string { return e.subscriberLabel ?? e.identity ?? "—"; } -/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps - * the time/label/identity/index columns aligned across rows; the detail line lives in - * its own row, indented under the identity column. */ +/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the + * time/label/#index columns aligned across rows; the identity, plate, badges and reason flow + * inline in the middle column and wrap there only when they run out of width. */ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) { const { t } = useTranslation(); const style = eventStyleFor(e); @@ -110,52 +110,44 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven const reason = renderReason(p, t); const amount = paymentSummary(p); const badges = eventBadges(p); - const via = viaKey(p); const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null); - const showDetail = detail != null || badges.length > 0 || via != null; return ( ); } @@ -163,8 +155,8 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven /** One label/value line in the event-detail modal. */ function DetailRow({ label, children }: { label: string; children: ReactNode }) { return ( -
- {label} +
+ {label} {children}
); @@ -203,19 +195,19 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
{label}
{(reason || money) && ( -
+
{reason ?? money}
)} {!reason && !money && isAnomaly && ( -
{t("booth.evtNoReason")}
+
{t("booth.evtNoReason")}
)} {badges.length > 0 && (
{badges.map((k) => ( {t(k)} @@ -235,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () = (the SUBSESS-… session key) for traceability against the ledger. */} {e.subscriberLabel && e.identity && ( - {e.identity} + {e.identity} )} {money && ( @@ -257,7 +249,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () = )} {tariffVersionId && ( - {tariffVersionId} + {tariffVersionId} )}
@@ -265,7 +257,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () = {/* The entry/exit evidence images for this session's identity. */} {e.identity && (
-
{t("booth.edSnapshots")}
+
{t("booth.edSnapshots")}
)} @@ -275,28 +267,28 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () = operator's; tucking them behind a disclosure keeps the common view clean while preserving the tamper-evidence trail on demand. */}
- + {t("booth.edAuditData")}
- {e.signature} + {e.signature} - {e.keyId} + {e.keyId} - {e.prevHash ?? "—"} + {e.prevHash ?? "—"} -
+
{t("booth.edRawPayload")}
{p && Object.keys(p).length > 0 ? ( -
+              
                 {JSON.stringify(p, null, 2)}
               
) : ( -
{t("booth.edNoPayload")}
+
{t("booth.edNoPayload")}
)}
diff --git a/wiki/log.md b/wiki/log.md index f6887a0..8fc4e31 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1829,3 +1829,16 @@ Three booth fixes + one prefs feature: the theme-pref pattern end to end (PUT /api/auth/font-scale, sessionView, setFontScalePref, applyFontScale in App). i18n sq+en. Tests: 4 font-scale auth-route cases (persist+/me, clamp/ snap, 400, default). Full workspace build/lint/test green (189 server tests). + +## [2026-06-28] fix | Font scale: rem-based root scaling (CSS `zoom` broke modal/footer layout) +The first cut of the per-user font scale used CSS `zoom` on the root so it would scale the app's +px-pinned type (text-[12px] etc.). But `zoom` scales the WHOLE box model including viewport-locked +containers — the h-screen app frame and max-h-[90vh] modals — so at 130% they overflowed the viewport +and modal headers/footers were pushed out of view (user had to scroll, big-modal chrome hidden). +Reworked to the correct fix: converted ALL `text-[Npx]` font utilities to rem across the web app +(~230 sites in 25 .tsx files + the .label/.hint/.btn component classes + body in index.css; 16px root +→ 12px=0.75rem etc., so 100% is visually identical), and applyFontScale now sets the ROOT font-size +(percent) instead of zoom. Only TEXT scales; vh/h-screen layout stays viewport-locked, so modals cap +at 90vh and scroll their own body — chrome never clips. Verified with Playwright: at 130% root, sample +text 12px→15.6px while the h-screen frame stayed exactly viewport-height and 90vh resolved unchanged. +Full workspace build/lint/test green.
{t("users.username")} {t("users.role")}