fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)
Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
(h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
font utility to rem across the web app (~230 sites in 25 files + the
.label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
layout stays put, so chrome never clips; tall content scrolls its own container.
Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.
Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
options already in the Type filter.
Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
name; overstay keeps a row tint). Removed the now-redundant status filter; only
the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).
Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
opening + cash-taken = expected reads clearly. Money values no longer line-wrap.
Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
following row by one column — it now emits a full label+value pair.
Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -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 --",
|
||||
|
||||
@@ -26,26 +26,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||
// reconciles via the pay/exit modal — never a free barrier open.
|
||||
// See wiki/concepts/booth-exit-flow.md.
|
||||
|
||||
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
|
||||
type KindFilter = "transient" | "subscription";
|
||||
|
||||
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
|
||||
if (s.subscription) return "subscription";
|
||||
if (s.overstay) return "overstay";
|
||||
if (!s.open && s.withinGrace) return "exiting";
|
||||
if (s.paidAt) return "paid";
|
||||
return "unpaid";
|
||||
}
|
||||
|
||||
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
|
||||
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
||||
if (s.overstay)
|
||||
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
|
||||
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
||||
}
|
||||
|
||||
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -70,9 +52,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
});
|
||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
||||
|
||||
// Filters: free-text search, status, and transient-vs-subscriber.
|
||||
// Filters: free-text search + transient-vs-subscriber. (No status filter — the status
|
||||
// column was dropped; an unpaid transient is normal and a subscriber is marked ★.)
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter | "">("");
|
||||
const [kind, setKind] = useState<KindFilter | "">("");
|
||||
|
||||
const sessions = useMemo(() => data?.sessions ?? [], [data]);
|
||||
@@ -81,7 +63,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
return sessions.filter((s) => {
|
||||
if (kind === "transient" && s.subscription) return false;
|
||||
if (kind === "subscription" && !s.subscription) return false;
|
||||
if (status && statusOf(s) !== status) return false;
|
||||
if (q) {
|
||||
// Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits.
|
||||
const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase();
|
||||
@@ -89,14 +70,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [sessions, search, status, kind]);
|
||||
}, [sessions, search, kind]);
|
||||
|
||||
const statusOpts: SegOption<StatusFilter>[] = [
|
||||
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
|
||||
{ value: "paid", label: t("booth.fStatusPaid") },
|
||||
{ value: "exiting", label: t("booth.fStatusExiting") },
|
||||
{ value: "overstay", label: t("booth.fStatusOverstay") },
|
||||
];
|
||||
const kindOpts: SegOption<KindFilter>[] = [
|
||||
{ value: "transient", label: t("booth.fKindTransient") },
|
||||
{ value: "subscription", label: t("booth.fKindSubscription") },
|
||||
@@ -120,7 +95,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
<Panel
|
||||
title={t("booth.activeSessions")}
|
||||
right={
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
{filtered.length}
|
||||
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
||||
</span>
|
||||
@@ -129,7 +104,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
||||
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
|
||||
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
||||
</FilterBar>
|
||||
|
||||
@@ -143,70 +117,87 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
: t("booth.noMatch")}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((s) => {
|
||||
const badge = statusBadge(s);
|
||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||
return (
|
||||
<div
|
||||
key={s.identity}
|
||||
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(s.identity)}
|
||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||
title={t("booth.openPayExit")}
|
||||
>
|
||||
<span className="text-term-text">
|
||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||
</span>
|
||||
{s.plate && (
|
||||
<span
|
||||
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
||||
title={t("booth.plateTitle")}
|
||||
>
|
||||
{s.plate}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||
<span
|
||||
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
|
||||
title={badge.titleKey ? t(badge.titleKey) : undefined}
|
||||
// A real table — aligned columns (who · plate · entry · elapsed · action). No
|
||||
// status column: an unpaid transient is the normal case, and a subscriber is
|
||||
// already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row
|
||||
// tint so that fraud-relevant signal isn't lost. The whole row is clickable
|
||||
// (→ pay/exit modal); the trailing cell holds the audited Open-barrier action.
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colWho")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
|
||||
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
|
||||
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
|
||||
<th className="px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => {
|
||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||
// Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid
|
||||
// but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and
|
||||
// NOT a subscription (assist-open lives in the modal). An unpaid transient
|
||||
// gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard.
|
||||
const canReopen = s.paidAt && !s.overstay && !s.subscription;
|
||||
return (
|
||||
<tr
|
||||
key={s.identity}
|
||||
onClick={() => onPick(s.identity)}
|
||||
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
|
||||
s.overstay ? "bg-term-red/5" : ""
|
||||
}`}
|
||||
title={t("booth.openPayExit")}
|
||||
>
|
||||
{t(badge.key)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
|
||||
re-pulse for a car that paid but the barrier didn't confirm. NOT an
|
||||
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
|
||||
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
|
||||
live in the pay/exit modal — the list must not offer a one-click open,
|
||||
which would bypass an unpaid window charge). An unpaid transient has no
|
||||
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
|
||||
{s.paidAt && !s.overstay && !s.subscription ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={() => handleReopen(s)}
|
||||
className="btn btn-pay btn-sm shrink-0"
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-[88px] shrink-0" />
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
<td className="px-2 py-1.5 text-term-text">
|
||||
{s.subscription ? (
|
||||
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
|
||||
) : (
|
||||
s.identity
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{s.plate && (
|
||||
<span
|
||||
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
||||
title={t("booth.plateTitle")}
|
||||
>
|
||||
{s.plate}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||
{formatRelativeDateTime(s.enteredAt, t)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||
{formatDuration(s.enteredAt, new Date().toISOString())}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{canReopen && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // don't also open the pay/exit modal
|
||||
void handleReopen(s);
|
||||
}}
|
||||
className="btn btn-pay btn-sm"
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
)}
|
||||
{msg && (
|
||||
<span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -226,7 +226,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{isSubscription
|
||||
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
||||
: `${t("pay.ticket")} ${identity}`}
|
||||
@@ -244,19 +244,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
||||
{blockedByOther ? (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateOtherTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">
|
||||
<div className="mt-1 text-[0.75rem] text-term-text">
|
||||
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
|
||||
<div className="mt-1 text-[0.75rem] text-term-text">{t("shift.gateBody")}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenShift}
|
||||
@@ -321,7 +321,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
out-of-window window charge; then show that amount. For an overstay the
|
||||
amount is the TOP-UP delta, not the whole stay. */}
|
||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
@@ -341,22 +341,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
gate; once paid, prompt the operator to open the barrier; a prepaid
|
||||
subscriber sees the assist explanation only after revealing it. */}
|
||||
{subWindowDue && !windowPaid ? (
|
||||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
|
||||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.windowChargeHint")}
|
||||
</div>
|
||||
) : isSubscription && windowPaid ? (
|
||||
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[12px] text-term-text">
|
||||
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.windowPaidHint")}
|
||||
</div>
|
||||
) : isSubscription && assistRevealed ? (
|
||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.subAssistHint")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
||||
{isOverstay && (
|
||||
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
|
||||
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.overstayHint")}
|
||||
</div>
|
||||
)}
|
||||
@@ -368,7 +368,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
subscriber window charge that's still unpaid). */}
|
||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
{(["cash", "card"] as const).map((tn) => (
|
||||
<button
|
||||
key={tn}
|
||||
@@ -384,7 +384,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
|
||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
||||
{phase !== "done" && !isSubscription && (
|
||||
<label className="flex items-center gap-2 text-[12px]">
|
||||
<label className="flex items-center gap-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
@@ -401,10 +401,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
signed `void` event — the entry is never edited. */}
|
||||
{voiding && phase !== "done" && (
|
||||
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<div className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("pay.cancelTicketTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||||
<div className="mt-1 text-[0.75rem] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
||||
<button
|
||||
@@ -560,7 +560,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className={`text-sm ${valueClass}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,13 +49,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||
<div className="pb-1 text-term-muted">
|
||||
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider">{t("booth.inside")}</div>
|
||||
<div className="text-sm tabular-nums">
|
||||
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
||||
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||
{occ.free == null ? "∞" : occ.free}
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
</div>
|
||||
)}
|
||||
{occ.full && (
|
||||
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
|
||||
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[0.6875rem] font-bold uppercase tracking-widest text-term-red">
|
||||
{t("booth.lotFull")}
|
||||
</div>
|
||||
)}
|
||||
@@ -106,7 +106,7 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-lg">
|
||||
{t("booth.open")}
|
||||
{t("booth.openTicket")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
@@ -134,7 +134,7 @@ function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; ra
|
||||
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
<div className="leading-tight">
|
||||
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,10 +181,10 @@ export function BoothScreen() {
|
||||
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
||||
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
|
||||
|
||||
// Live-feed filters: free-text search, event category, and direction/source.
|
||||
// Live-feed filters: free-text search, event type, and source. (No direction filter —
|
||||
// HYRJE/DALJE there just duplicated the entry/exit options already in the Type filter.)
|
||||
const [feedSearch, setFeedSearch] = useState("");
|
||||
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
||||
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
|
||||
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
||||
|
||||
// Live overlays from the WS store.
|
||||
@@ -212,7 +212,6 @@ export function BoothScreen() {
|
||||
const fq = feedSearch.trim().toLowerCase();
|
||||
const events = scoped.filter((e) => {
|
||||
if (feedType && feedCat(e.type) !== feedType) return false;
|
||||
if (feedDir && e.direction !== feedDir) return false;
|
||||
if (feedSrc) {
|
||||
const isBooth = e.source === "manual";
|
||||
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
||||
@@ -231,10 +230,6 @@ export function BoothScreen() {
|
||||
{ value: "void", label: t("booth.fEvtVoid") },
|
||||
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
|
||||
];
|
||||
const feedDirOpts: SegOption<"entry" | "exit">[] = [
|
||||
{ value: "entry", label: t("booth.fDirEntry") },
|
||||
{ value: "exit", label: t("booth.fDirExit") },
|
||||
];
|
||||
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
|
||||
{ value: "booth", label: t("booth.fSrcBooth") },
|
||||
{ value: "reader", label: t("booth.fSrcReader") },
|
||||
@@ -272,7 +267,7 @@ export function BoothScreen() {
|
||||
<Panel
|
||||
title={t("booth.liveFeed")}
|
||||
right={
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
{events.length}
|
||||
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
||||
</span>
|
||||
@@ -288,7 +283,6 @@ export function BoothScreen() {
|
||||
onChange={setFeedType}
|
||||
allLabel={t("booth.filterAll")}
|
||||
/>
|
||||
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
|
||||
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
|
||||
</FilterBar>
|
||||
)}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mb-3 text-[12px] text-term-red">{error}</p>}
|
||||
{error && <p className="mb-3 text-[0.75rem] text-term-red">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
|
||||
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</button>
|
||||
|
||||
@@ -31,7 +31,7 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasDetail && setOpen((v) => !v)}
|
||||
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[12px] ${
|
||||
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[0.75rem] ${
|
||||
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
|
||||
}`}
|
||||
>
|
||||
@@ -44,17 +44,17 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
||||
{open && hasDetail && (
|
||||
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
||||
{log.path && (
|
||||
<div className="mb-1 text-[11px] text-term-muted">
|
||||
<div className="mb-1 text-[0.6875rem] text-term-muted">
|
||||
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
|
||||
</div>
|
||||
)}
|
||||
{log.context && Object.keys(log.context).length > 0 && (
|
||||
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-text">
|
||||
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-text">
|
||||
{JSON.stringify(log.context, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{log.stack && (
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-red/90">
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-red/90">
|
||||
{log.stack}
|
||||
</pre>
|
||||
)}
|
||||
@@ -136,12 +136,12 @@ export function LogsViewer() {
|
||||
|
||||
<div className="card p-2">
|
||||
{q.isLoading ? (
|
||||
<div className="p-3 text-[12px] text-term-muted">{t("common.loading")}</div>
|
||||
<div className="p-3 text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-3 text-[12px] text-term-muted">{t("logs.empty")}</div>
|
||||
<div className="p-3 text-[0.75rem] text-term-muted">{t("logs.empty")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
<span>{t("logs.time")}</span>
|
||||
<span>{t("logs.level")}</span>
|
||||
<span>{t("logs.source")}</span>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function Profile({
|
||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||
{t("profile.accountSection")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
|
||||
<div className="grid grid-cols-2 gap-3 text-[0.6875rem] text-term-muted">
|
||||
<div>
|
||||
<span className="block">{t("profile.username")}</span>
|
||||
<span className="text-sm text-term-text">{user.username}</span>
|
||||
@@ -92,7 +92,7 @@ export function Profile({
|
||||
<span className="text-sm text-term-text">{user.roleName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.fullName")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -101,7 +101,7 @@ export function Profile({
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.email")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -115,7 +115,7 @@ export function Profile({
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
||||
{t("profile.saveProfile")}
|
||||
</button>
|
||||
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
|
||||
{accountMsg && <span className="text-[0.6875rem] text-term-muted">{accountMsg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function Profile({
|
||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||
{t("profile.passwordSection")}
|
||||
</h2>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.currentPassword")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -134,7 +134,7 @@ export function Profile({
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.newPassword")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -144,7 +144,7 @@ export function Profile({
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||
{t("profile.confirmPassword")}
|
||||
<input
|
||||
className="input"
|
||||
@@ -163,7 +163,7 @@ export function Profile({
|
||||
>
|
||||
{t("profile.changePassword")}
|
||||
</button>
|
||||
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
|
||||
{pwMsg && <span className="text-[0.6875rem] text-term-muted">{pwMsg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -76,13 +76,13 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||
{t("recycleBin.title")}
|
||||
</h1>
|
||||
{retentionDays > 0 && (
|
||||
<span className="text-[12px] text-term-muted">
|
||||
<span className="text-[0.75rem] text-term-muted">
|
||||
{t("recycleBin.retentionNote", { days: retentionDays })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
|
||||
{error && <p className="mb-2 text-[0.75rem] text-term-red">{error}</p>}
|
||||
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
||||
|
||||
{!binQ.isLoading && items.length === 0 ? (
|
||||
@@ -90,9 +90,9 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||
{t("recycleBin.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-[13px]">
|
||||
<table className="w-full text-[0.8125rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<tr className="border-b border-term-border text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
|
||||
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
|
||||
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
|
||||
@@ -103,7 +103,7 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||
{items.map((it) => (
|
||||
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
|
||||
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[0.6875rem] text-term-muted">
|
||||
{t(KIND_KEY[it.kind])}
|
||||
</span>
|
||||
</td>
|
||||
@@ -146,10 +146,10 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||
|
||||
{purging && (
|
||||
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
||||
<p className="text-[13px] text-term-text">
|
||||
<p className="text-[0.8125rem] text-term-text">
|
||||
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
||||
<p className="mt-1 text-[0.75rem] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
||||
{t("common.cancel")}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function Reports() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-1 text-[12px] text-term-muted">
|
||||
<div className="ml-2 flex items-center gap-1 text-[0.75rem] text-term-muted">
|
||||
<span>{t("reports.groupBy")}</span>
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
@@ -247,7 +247,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
|
||||
{/* Cash / card + duration + subscription breakdown (numbers). */}
|
||||
<Panel title={t("reports.chart.breakdown")}>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]">
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[0.8125rem]">
|
||||
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
|
||||
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
|
||||
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
|
||||
@@ -262,7 +262,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-term-muted">
|
||||
<p className="text-[0.6875rem] text-term-muted">
|
||||
{t("reports.footnote", { tz: data.tz })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -290,7 +290,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
|
||||
: "text-term-text";
|
||||
return (
|
||||
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -299,7 +299,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-term border border-term-border bg-term-panel p-3">
|
||||
<h2 className="mb-2 text-[11px] uppercase tracking-wider text-term-muted">{title}</h2>
|
||||
<h2 className="mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -315,5 +315,5 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
function Empty({ t }: { t: TFunction }) {
|
||||
return <p className="py-12 text-center text-[12px] text-term-muted">{t("reports.noData")}</p>;
|
||||
return <p className="py-12 text-center text-[0.75rem] text-term-muted">{t("reports.noData")}</p>;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
|
||||
|
||||
<Modal
|
||||
open={editing != null}
|
||||
@@ -93,13 +93,13 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-term-text">{r.name}</span>
|
||||
<span className="text-[0.8125rem] font-semibold text-term-text">{r.name}</span>
|
||||
{r.builtin && (
|
||||
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-term-amber">
|
||||
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{t("roles.builtin")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-term-muted">
|
||||
<span className="text-[0.6875rem] text-term-muted">
|
||||
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -155,11 +155,11 @@ function RoleEditor({
|
||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||
{Object.entries(grouped).map(([resource, list]) => (
|
||||
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
||||
<span className="w-28 shrink-0 text-[12px] font-semibold text-term-text">{resource}</span>
|
||||
<span className="w-28 shrink-0 text-[0.75rem] font-semibold text-term-text">{resource}</span>
|
||||
{list.map((p) => {
|
||||
const action = p.split(":")[1]!;
|
||||
return (
|
||||
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
|
||||
<label key={p} className="flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||||
{action}
|
||||
</label>
|
||||
|
||||
@@ -206,8 +206,8 @@ function CategorySection({
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
|
||||
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
|
||||
<strong className="text-[0.75rem] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||
<ul className="mt-1 list-disc pl-5 text-[0.75rem] text-term-amber">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
@@ -233,7 +233,7 @@ function CategorySection({
|
||||
)}
|
||||
|
||||
{blockedNoController ? (
|
||||
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||
<p className="m-0 text-[0.75rem] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||
) : (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
|
||||
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
|
||||
@@ -301,7 +301,7 @@ function AssignmentRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
|
||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[0.75rem]">
|
||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||
@@ -690,19 +690,19 @@ function DeviceForm({
|
||||
|
||||
{selected && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
|
||||
<p className="mb-2 text-[0.75rem] text-term-muted">{selected.description}</p>
|
||||
|
||||
{canDiscover && (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
|
||||
{scanning ? t("setup.scanning") : t("setup.scan")}
|
||||
</button>
|
||||
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
|
||||
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||
{scanError && <span className="ml-2 text-[0.75rem] text-term-red">{scanError}</span>}
|
||||
{found && found.length === 0 && <p className="mt-2 text-[0.75rem] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||
{found && found.length > 0 && (
|
||||
<ul className="mt-2 list-none p-0">
|
||||
{found.map((d) => (
|
||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
|
||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[0.75rem]">
|
||||
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
|
||||
{t("setup.use")}
|
||||
</button>
|
||||
@@ -734,7 +734,7 @@ function DeviceForm({
|
||||
f.type === "boolean" ? (
|
||||
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
||||
// the string "true"). The label sits beside the box, with the help below.
|
||||
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -859,7 +859,7 @@ function DeviceForm({
|
||||
|
||||
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
|
||||
{isCamera && (
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -877,7 +877,7 @@ function DeviceForm({
|
||||
(evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where
|
||||
the exit cam's back-plate read would phantom-exit a car that just entered. */}
|
||||
{isCamera && anpr && (
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -901,7 +901,7 @@ function DeviceForm({
|
||||
a Test connection first. We surface each field separately, matching the
|
||||
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
|
||||
{isCamera && Boolean(config.alarmPushEnabled) && (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
|
||||
{!editing?.id ? (
|
||||
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
|
||||
@@ -964,9 +964,9 @@ function DeviceForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||
{testError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||
{tested && (
|
||||
<div className="mt-2 text-[12px]">
|
||||
<div className="mt-2 text-[0.75rem]">
|
||||
<div className="text-term-text">
|
||||
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
|
||||
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
|
||||
@@ -993,10 +993,10 @@ function DeviceForm({
|
||||
</button>
|
||||
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
|
||||
|
||||
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
||||
{anprError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
||||
{anprResult &&
|
||||
(anprResult.ok ? (
|
||||
<div className="mt-2 text-[12px] text-term-green">
|
||||
<div className="mt-2 text-[0.75rem] text-term-green">
|
||||
{t("setup.anprOk", {
|
||||
plate: anprResult.plate,
|
||||
confidence: Math.round(anprResult.confidence * 100),
|
||||
@@ -1007,7 +1007,7 @@ function DeviceForm({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-[12px] text-term-amber">
|
||||
<div className="mt-2 text-[0.75rem] text-term-amber">
|
||||
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
|
||||
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
|
||||
{anprResult.tookMs != null && (
|
||||
@@ -1027,14 +1027,14 @@ function DeviceForm({
|
||||
</button>
|
||||
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
|
||||
|
||||
{printError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
|
||||
{printError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
|
||||
{printResult &&
|
||||
(printResult.ok ? (
|
||||
<div className="mt-2 text-[12px] text-term-green">
|
||||
<div className="mt-2 text-[0.75rem] text-term-green">
|
||||
{t("setup.printOk", { ms: printResult.tookMs })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-[12px] text-term-amber">
|
||||
<div className="mt-2 text-[0.75rem] text-term-amber">
|
||||
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
|
||||
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
|
||||
</div>
|
||||
@@ -1060,12 +1060,12 @@ function DeviceForm({
|
||||
</select>
|
||||
</div>
|
||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||
<span className="text-[0.75rem] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||
)}
|
||||
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||
{saveError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1105,11 +1105,11 @@ function OutputEditor({
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
|
||||
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
|
||||
|
||||
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
|
||||
<label className="my-1 inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.pulseOpenHint")}>
|
||||
<label className="my-1 inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.pulseOpenHint")}>
|
||||
{t("setup.pulseOpenMs")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1125,7 +1125,7 @@ function OutputEditor({
|
||||
barriers pulse (their button/presence terminals are in the Inputs section). */}
|
||||
{relays.map((r, i) => (
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1150,7 +1150,7 @@ function OutputEditor({
|
||||
{/* Alert relay: which input fires the blink + the blink cadence. */}
|
||||
{r.direction === "radarAlert" && (
|
||||
<>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.triggerInputHint")}>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.triggerInputHint")}>
|
||||
{t("setup.triggerInput")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1161,7 +1161,7 @@ function OutputEditor({
|
||||
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.lockLaneHint")}>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.lockLaneHint")}>
|
||||
{t("setup.lockLane")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
@@ -1172,7 +1172,7 @@ function OutputEditor({
|
||||
<option value="exit">{t("setup.lockLaneExit")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.blinkOnMs")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1183,7 +1183,7 @@ function OutputEditor({
|
||||
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.blinkOffMs")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1251,11 +1251,11 @@ function InputEditor({
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
|
||||
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
|
||||
|
||||
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
|
||||
<label className="my-1 inline-flex items-start gap-2 text-[12px] text-term-muted">
|
||||
<label className="my-1 inline-flex items-start gap-2 text-[0.75rem] text-term-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -1274,7 +1274,7 @@ function InputEditor({
|
||||
const isButton = row.role === "button";
|
||||
return (
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.inputTerminal")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1298,7 +1298,7 @@ function InputEditor({
|
||||
|
||||
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
|
||||
{row.role !== "alertTrigger" && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.inputServesRelay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
@@ -1319,7 +1319,7 @@ function InputEditor({
|
||||
|
||||
{/* Presence: active-low (a radar wired opposite the button). */}
|
||||
{isPresence && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.activeLowHint")}>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.activeLowHint")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!row.activeLow}
|
||||
@@ -1331,7 +1331,7 @@ function InputEditor({
|
||||
|
||||
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
|
||||
{isButton && !hasPresenceFor(row.relay) && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||
{t("setup.entryCooldown")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1381,9 +1381,9 @@ function BindingPicker({
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.controller")}
|
||||
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
@@ -1400,7 +1400,7 @@ function BindingPicker({
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
@@ -1424,7 +1424,7 @@ function BindingPicker({
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
||||
</div>
|
||||
{controller && relays.length === 0 && (
|
||||
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||
<p className="mt-1.5 text-[0.75rem] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1441,7 +1441,7 @@ function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: s
|
||||
? "border-term-red text-term-red"
|
||||
: "border-term-muted text-term-muted";
|
||||
return (
|
||||
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
<span className={`rounded-term border px-1.5 text-[0.625rem] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
{label ?? direction}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -175,7 +175,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
</div>
|
||||
|
||||
{q.isError && (
|
||||
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
|
||||
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{t("shifts.loadFailed")}</div>
|
||||
)}
|
||||
|
||||
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
|
||||
@@ -183,7 +183,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
||||
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||
{!q.isLoading && list.length === 0 && (
|
||||
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
||||
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
|
||||
)}
|
||||
{list.map((s) => (
|
||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||
@@ -202,7 +202,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
onChanged={refreshAll}
|
||||
/>
|
||||
) : (
|
||||
<p className="px-3 py-6 text-center text-[12px] text-term-muted">{t("shifts.selectAShift")}</p>
|
||||
<p className="px-3 py-6 text-center text-[0.75rem] text-term-muted">{t("shifts.selectAShift")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +228,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
||||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||
</button>
|
||||
@@ -244,11 +244,11 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`card w-full p-2.5 text-left text-[12px] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
||||
className={`card w-full p-2.5 text-left text-[0.75rem] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||
{open && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
||||
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||
{showOperator ? s.operator : when(s.startedAt)}
|
||||
</span>
|
||||
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||
@@ -300,9 +300,9 @@ function ShiftActivityLog({
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||
{showOperator && `${shift.operator} · `}
|
||||
{formatRelativeDateTime(shift.startedAt, t)}
|
||||
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||
@@ -316,10 +316,10 @@ function ShiftActivityLog({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[0.6875rem] tabular-nums sm:grid-cols-4">
|
||||
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||
<Figure label={t("shifts.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||
@@ -331,8 +331,8 @@ function ShiftActivityLog({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
||||
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
|
||||
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
|
||||
{q.isLoading && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("common.loading")}</p>}
|
||||
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.noActivity")}</p>}
|
||||
{events.map((e) => (
|
||||
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
||||
))}
|
||||
@@ -372,14 +372,15 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
|
||||
{report ? (
|
||||
// Result — the signed Z-report.
|
||||
<div className="text-[13px] tabular-nums">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
||||
<span />
|
||||
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
||||
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
||||
<Figure label={t("shift.srcSubSales")} value={money(report.subscriptionSalesMinor, report.currency)} sub />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
@@ -399,20 +400,24 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
</div>
|
||||
) : (
|
||||
// Confirm — show the live takings (split by source) + drawer before closing.
|
||||
<div className="text-[13px] tabular-nums">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||
<Figure label={t("shift.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
||||
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||
<span />
|
||||
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||
</div>
|
||||
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
||||
{err && <p className="mt-2 text-[0.75rem] text-term-red">{err}</p>}
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
|
||||
@@ -452,7 +457,7 @@ function VoucherModal({ currency, onClose, onDone }: { currency: string | null;
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
||||
<div className="flex flex-col gap-2 text-[13px]">
|
||||
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
|
||||
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
|
||||
@@ -461,8 +466,8 @@ function VoucherModal({ currency, onClose, onDone }: { currency: string | null;
|
||||
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
|
||||
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
|
||||
</div>
|
||||
<div className="text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
||||
{msg && <div className="text-[12px] text-term-muted">{msg}</div>}
|
||||
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
|
||||
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
|
||||
@@ -480,16 +485,17 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
||||
{!x ? (
|
||||
<p className="text-[12px] text-term-muted">{t("common.loading")}</p>
|
||||
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<div className="text-[13px] tabular-nums">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||
<span />
|
||||
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
||||
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
||||
<Figure label={t("shift.srcSubSales")} value={money(x.subscriptionSalesMinor, x.currency)} sub />
|
||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
@@ -500,7 +506,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
||||
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-term-muted">{t("shift.xReportHint")}</div>
|
||||
<div className="mt-2 text-[0.6875rem] text-term-muted">{t("shift.xReportHint")}</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
@@ -513,8 +519,9 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||
return (
|
||||
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||
<span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span>
|
||||
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
|
||||
<span className={`whitespace-nowrap ${sub ? "text-term-muted/70" : "text-term-muted"}`}>{label}</span>
|
||||
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
|
||||
return (
|
||||
<section className="card mt-6 max-w-md p-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[13px]">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||
{occ == null ? (
|
||||
<span className="text-term-muted">…</span>
|
||||
@@ -93,7 +93,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
<span className="label">{t("site.capacityLabel")}</span>
|
||||
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
||||
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
@@ -103,7 +103,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
{t("site.printExitDefault")}
|
||||
<span className="hint">{t("site.printExitHint")}</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
||||
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-term-amber"
|
||||
@@ -115,7 +115,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
||||
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-term-amber"
|
||||
@@ -127,7 +127,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("site.parkDetails")}
|
||||
</div>
|
||||
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||
@@ -153,7 +153,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
||||
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
|
||||
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -412,7 +412,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
||||
<ul className="mb-3 list-none p-0">
|
||||
{subs.map((s) => (
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[0.75rem]">
|
||||
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
|
||||
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
|
||||
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
|
||||
@@ -464,13 +464,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
||||
{plans.length === 0 && <span className="text-[0.75rem] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="label">{t("subs.plan")}</label>
|
||||
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
||||
<span className="text-[0.8125rem] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
||||
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
|
||||
move the sub to a different VERSION of that same plan (e.g. one with
|
||||
different timeframes). Price stays as billed. Only shown when the sub has
|
||||
@@ -486,7 +486,15 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
if (cur) versions.unshift(cur);
|
||||
}
|
||||
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
|
||||
return <span className="text-[12px] text-term-muted">{t("subs.versionOnlyOne")}</span>;
|
||||
// Keep the 2-col grid flow intact: a lone cell here would shift every
|
||||
// following row by one column (label↔input swap). Emit a full row —
|
||||
// the version label + the "only one version" hint as its control.
|
||||
return (
|
||||
<>
|
||||
<label className="label">{t("subs.version")}</label>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("subs.versionOnlyOne")}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -504,7 +512,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.versionHint")}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("subs.versionHint")}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -523,7 +531,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
inputMode="numeric"
|
||||
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
|
||||
/>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("subs.quantityHint")}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -533,7 +541,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
<>
|
||||
<label className="label">{t("subs.tender")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="radio"
|
||||
name="tender"
|
||||
@@ -543,7 +551,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
/>
|
||||
{t("subs.tenderCash")}
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="radio"
|
||||
name="tender"
|
||||
@@ -553,13 +561,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
/>
|
||||
{t("subs.tenderCard")}
|
||||
</label>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("subs.tenderHint")}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<label className="label">{t("subs.carLimit")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||||
</label>
|
||||
{form.carBound && (
|
||||
@@ -580,7 +588,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
inputMode="numeric"
|
||||
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
|
||||
/>
|
||||
<span className="text-[12px] text-term-muted">
|
||||
<span className="text-[0.75rem] text-term-muted">
|
||||
× {t(PERIOD_KEY[selectedPlan.period])}
|
||||
</span>
|
||||
</span>
|
||||
@@ -592,7 +600,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
|
||||
override it — this is exactly what will be charged + signed. */}
|
||||
{editing === "new" && form.planId.trim() !== "" && (
|
||||
<span className="text-[12px] text-term-cyan">
|
||||
<span className="text-[0.75rem] text-term-cyan">
|
||||
{quoting
|
||||
? t("subs.quoting")
|
||||
: quote
|
||||
@@ -610,7 +618,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||
</div>
|
||||
|
||||
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
||||
<h4 className="mt-4 mb-1 text-[0.75rem] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} className="mb-1.5 flex items-center gap-2">
|
||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||
@@ -625,7 +633,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
c.value.trim() ? (
|
||||
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
|
||||
) : (
|
||||
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
||||
<span className="flex-1 self-center text-[0.75rem] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
||||
)
|
||||
) : (
|
||||
// RFID: the value is read off a physical card (or typed). "Read card"
|
||||
@@ -643,7 +651,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||
the credential. The OTHER reader keeps serving the live flow. */}
|
||||
{capture && (
|
||||
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
|
||||
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[0.75rem]">
|
||||
{capture.phase === "pick" ? (
|
||||
<>
|
||||
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
|
||||
@@ -673,7 +681,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
|
||||
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[0.75rem] text-term-green" : "mt-3 text-[0.75rem] text-term-red"}>{msg.text}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,19 +237,19 @@ export function SubscriptionPlansManager() {
|
||||
return (
|
||||
<section className="px-4 py-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||
<h3 className="text-[0.8125rem] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||
{t("plans.add")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
|
||||
<p className="mb-3 text-[0.75rem] text-term-muted">{t("plans.intro")}</p>
|
||||
|
||||
{msg && (
|
||||
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||
<div className={`mb-3 text-[0.75rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||
)}
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
|
||||
<p className="text-[0.8125rem] text-term-muted">{t("plans.noneYet")}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{groups.map(({ planId, head: p, active, versions }) => {
|
||||
@@ -263,15 +263,15 @@ export function SubscriptionPlansManager() {
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-semibold text-term-text">{p.name}</span>
|
||||
{active ? (
|
||||
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
|
||||
<span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("plans.inForce")}</span>
|
||||
) : (
|
||||
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
|
||||
<span className="rounded border border-term-border px-1 text-[0.625rem] text-term-muted">{t("plans.retired")}</span>
|
||||
)}
|
||||
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
||||
{versions > 1 && <span className="text-[0.625rem] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
||||
</div>
|
||||
|
||||
{/* Details: price · hours · effective */}
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted">
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[0.75rem] text-term-muted">
|
||||
<span className="tabular-nums text-term-text">
|
||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||
</span>
|
||||
@@ -280,7 +280,7 @@ export function SubscriptionPlansManager() {
|
||||
</div>
|
||||
|
||||
{/* Used by */}
|
||||
<div className="mt-1 text-[12px]">
|
||||
<div className="mt-1 text-[0.75rem]">
|
||||
{users.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -295,12 +295,12 @@ export function SubscriptionPlansManager() {
|
||||
)}
|
||||
</div>
|
||||
{isOpen && users.length > 0 && (
|
||||
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]">
|
||||
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[0.75rem]">
|
||||
{users.map((s) => (
|
||||
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
||||
{s.holderName || t("subs.unnamed")}
|
||||
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
||||
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
|
||||
{s.status !== "active" && <span className="ml-1 text-[0.625rem]">({t(STATUS_KEY[s.status])})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -349,14 +349,14 @@ export function SubscriptionPlansManager() {
|
||||
<span className="flex items-center gap-2">
|
||||
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
||||
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
||||
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
||||
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
@@ -370,7 +370,7 @@ export function SubscriptionPlansManager() {
|
||||
<label className="label">{t("plans.days")}</label>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{DOW_ORDER.map((d) => (
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
@@ -386,7 +386,7 @@ export function SubscriptionPlansManager() {
|
||||
))}
|
||||
</span>
|
||||
<label className="label">{t("plans.window")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
|
||||
<span className="flex flex-wrap items-center gap-2 text-[0.75rem] text-term-muted">
|
||||
{t("plans.enterAfter")}
|
||||
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
|
||||
{t("plans.exitBefore")}
|
||||
@@ -395,14 +395,14 @@ export function SubscriptionPlansManager() {
|
||||
<label className="label">{t("plans.grace")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
|
||||
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
|
||||
<span className="text-[0.75rem] text-term-muted">{t("plans.graceHint")}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
|
||||
<p className="mt-1.5 text-[0.6875rem] text-term-muted">{t("plans.timeframesHint")}</p>
|
||||
</div>
|
||||
|
||||
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||
{form.planId && <p className="mt-2 text-[0.6875rem] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
|
||||
|
||||
@@ -354,11 +354,11 @@ export function TariffComposer() {
|
||||
<section className="px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
||||
{!state?.active ? (
|
||||
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
|
||||
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||
{t("tariff.noRateCard")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-[12px] text-term-muted">
|
||||
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||
{t("tariff.activeSince", {
|
||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||
count: state.versions.length,
|
||||
@@ -407,7 +407,7 @@ export function TariffComposer() {
|
||||
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
||||
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[12px] text-term-red">
|
||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
|
||||
{t("tariff.steppedTiersConflict")}
|
||||
</p>
|
||||
)}
|
||||
@@ -432,7 +432,7 @@ export function TariffComposer() {
|
||||
<label className="label">{t("tariff.tierDays")}</label>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
{t(`tariff.dow${d}`)}
|
||||
</label>
|
||||
@@ -444,7 +444,7 @@ export function TariffComposer() {
|
||||
<span className="text-term-muted">–</span>
|
||||
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||
)}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierDates")}</label>
|
||||
@@ -478,7 +478,7 @@ export function TariffComposer() {
|
||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||
</button>
|
||||
{msg && (
|
||||
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
|
||||
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -504,7 +504,7 @@ function PricingEditor(props: {
|
||||
const { t, pricing: p } = props;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex gap-4 text-[12px]">
|
||||
<div className="mb-3 flex gap-4 text-[0.75rem]">
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
{t("tariff.modeLadder")}
|
||||
@@ -538,7 +538,7 @@ function PricingEditor(props: {
|
||||
<td className="px-2 py-1">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
@@ -587,7 +587,7 @@ function PricingEditor(props: {
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -131,7 +131,7 @@ export function TariffLab() {
|
||||
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
||||
{t("lab.load")}
|
||||
</button>
|
||||
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
|
||||
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
|
||||
</div>
|
||||
|
||||
{/* Hypothetical session inputs */}
|
||||
@@ -167,7 +167,7 @@ export function TariffLab() {
|
||||
|
||||
<label className="label">{t("lab.payment")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||
<label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
||||
{t("lab.paid")}
|
||||
</label>
|
||||
@@ -190,7 +190,7 @@ export function TariffLab() {
|
||||
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
||||
{busy ? t("lab.pricing") : t("lab.price")}
|
||||
</button>
|
||||
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
||||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
@@ -198,19 +198,19 @@ export function TariffLab() {
|
||||
{/* Outcome */}
|
||||
<div className="card card-body">
|
||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px]">
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
|
||||
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
||||
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
||||
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
||||
<dd className="text-term-text">
|
||||
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
||||
{result.pricing.overstay && (
|
||||
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[10px] uppercase text-term-red">
|
||||
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
|
||||
{t("lab.overstay")}
|
||||
</span>
|
||||
)}
|
||||
{result.pricing.withinGrace && (
|
||||
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[10px] uppercase text-term-green">
|
||||
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
|
||||
{t("lab.settled")}
|
||||
</span>
|
||||
)}
|
||||
@@ -230,7 +230,7 @@ export function TariffLab() {
|
||||
<div className="card card-body">
|
||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
||||
<p className="hint mb-2">{t("lab.curveHint")}</p>
|
||||
<table className="w-full text-[12px] tabular-nums">
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<tbody>
|
||||
{result.curve.map((c) => (
|
||||
<tr key={c.minutes} className="border-b border-term-border/40">
|
||||
|
||||
@@ -51,7 +51,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
|
||||
|
||||
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
|
||||
<UserForm
|
||||
@@ -100,8 +100,8 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
||||
</Modal>
|
||||
|
||||
<div className="overflow-hidden rounded-term border border-term-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<table className="w-full text-[0.75rem]">
|
||||
<thead className="bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.username")}</th>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.role")}</th>
|
||||
@@ -291,7 +291,7 @@ function UserForm({
|
||||
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
|
||||
|
||||
{/* Optional profile metadata. */}
|
||||
<div className="mt-4 mb-2 text-[11px] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
|
||||
<div className="mt-4 mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("users.fullName")}</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,13 +13,14 @@ export function applyTheme(theme: Theme): void {
|
||||
document.documentElement.classList.toggle("theme-light", theme === "light");
|
||||
}
|
||||
|
||||
/** Apply a font scale as a whole-UI ZOOM (`pct`% on the root). The app's type is pinned in
|
||||
* px (`text-[12px]` etc.), which a root font-size would NOT scale — `zoom` scales everything
|
||||
* uniformly (text, spacing, icons), exactly like the browser's Ctrl+/−, so the feed/session
|
||||
* logs grow too. Clamped to the allowed band; no-op-safe to call repeatedly. */
|
||||
/** Apply a font scale by setting the ROOT font-size (percent). The app's text is sized in
|
||||
* rem (the `text-[…rem]` utilities + the .label/.input/.hint/.btn component classes all
|
||||
* derive from the root), so only TEXT scales — viewport-locked layout (h-screen frame,
|
||||
* max-h-[90vh] modals, vh units) is unaffected, so headers/footers never clip; taller
|
||||
* content just scrolls its own container. NOT `zoom` (which scaled those vh boxes too and
|
||||
* pushed modal chrome out of view). Clamped to the band; no-op-safe to call repeatedly. */
|
||||
export function applyFontScale(pct: number): void {
|
||||
const clamped = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(pct)));
|
||||
// `zoom` is supported in all the booth's target browsers (Chromium/WebKit/modern FF).
|
||||
// 1 = 100%. Reset to "" at base so we don't leave an inline override lying around.
|
||||
document.documentElement.style.zoom = clamped === 100 ? "" : String(clamped / 100);
|
||||
// 100% = the browser's 16px root. The app's rem units scale off this.
|
||||
document.documentElement.style.fontSize = clamped === 100 ? "" : `${clamped}%`;
|
||||
}
|
||||
|
||||
+21
-13
@@ -68,7 +68,7 @@ function NavLink({ to, label }: { to: string; label: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
||||
className="px-2 py-1 text-[0.6875rem] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
@@ -82,7 +82,7 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
|
||||
<Link
|
||||
to={to}
|
||||
activeOptions={{ exact }}
|
||||
className="border-b-2 border-transparent px-3 py-2 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
|
||||
className="border-b-2 border-transparent px-3 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
@@ -159,7 +159,7 @@ function LanguageToggle({
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
||||
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||
{(["sq", "en"] as const).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
@@ -205,7 +205,7 @@ function ThemeToggle({
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
||||
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||
{(["dark", "light"] as const).map((th) => (
|
||||
<button
|
||||
key={th}
|
||||
@@ -241,7 +241,7 @@ function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: Se
|
||||
}
|
||||
const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
||||
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||
<button type="button" className={btn} onClick={() => step(-FONT_SCALE_STEP)} disabled={active <= FONT_SCALE_MIN} title={t("common.fontSmaller")} aria-label={t("common.fontSmaller")}>
|
||||
A−
|
||||
</button>
|
||||
@@ -316,14 +316,14 @@ function ShiftButton() {
|
||||
disabled={busy || blockedByOther}
|
||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||
onClick={onClick}
|
||||
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
|
||||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`}
|
||||
>
|
||||
{busy ? t("shift.opening") : label}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
)}
|
||||
{err && <span className="text-[10px] text-term-red">{err}</span>}
|
||||
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||||
{confirmingClose && (
|
||||
<CloseShiftConfirm
|
||||
busy={busy}
|
||||
@@ -358,7 +358,7 @@ function CloseShiftConfirm({
|
||||
|
||||
return (
|
||||
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
||||
<div className="text-[13px] tabular-nums">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||
{!x ? (
|
||||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||||
@@ -370,12 +370,17 @@ function CloseShiftConfirm({
|
||||
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.srcSubSales")} value={fmt(x.subscriptionSalesMinor)} sub />
|
||||
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
|
||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||||
</div>
|
||||
</>
|
||||
@@ -396,10 +401,13 @@ function CloseShiftConfirm({
|
||||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||
<span className={`text-[11px] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}>
|
||||
<span
|
||||
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
|
||||
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -448,7 +456,7 @@ function RootLayout() {
|
||||
<Link
|
||||
to="/profile"
|
||||
title={t("nav.profile")}
|
||||
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
|
||||
className="text-[0.6875rem] text-term-muted hover:text-term-text [&.active]:text-term-amber"
|
||||
>
|
||||
{user.username} · {user.roleName}
|
||||
</Link>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function DeviceFooter() {
|
||||
return (
|
||||
<footer
|
||||
ref={rootRef}
|
||||
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]"
|
||||
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[0.6875rem]"
|
||||
>
|
||||
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
|
||||
{t("devices.footerTitle")}
|
||||
@@ -156,7 +156,7 @@ export function DeviceFooter() {
|
||||
{open && problems.length > 0 && (
|
||||
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("devices.issuesTitle")}
|
||||
</span>
|
||||
<button
|
||||
@@ -177,13 +177,13 @@ export function DeviceFooter() {
|
||||
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span className={`text-[0.75rem] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
||||
<span className="shrink-0 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
{t(`devices.state.${d.state}`)}
|
||||
</span>
|
||||
</div>
|
||||
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
|
||||
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
|
||||
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
||||
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function SegGroup<V extends string>({
|
||||
key={v || "all"}
|
||||
type="button"
|
||||
onClick={() => onChange(v)}
|
||||
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${
|
||||
className={`px-2 py-0.5 text-[0.625rem] uppercase tracking-wider transition-colors ${
|
||||
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
@@ -61,7 +61,7 @@ export function FilterBar({
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
|
||||
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[0.75rem] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function Modal({
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
||||
|
||||
@@ -21,7 +21,7 @@ export function Panel({
|
||||
>
|
||||
{title && (
|
||||
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
|
||||
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
<h2 className="m-0 text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{title}
|
||||
</h2>
|
||||
{right}
|
||||
|
||||
@@ -37,9 +37,9 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
const dirLabel = (dir: "entry" | "exit" | null): string =>
|
||||
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
|
||||
|
||||
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||
if (isLoading) return <div className="text-[0.6875rem] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||
if (shots.length === 0 && failures.length === 0 && plates.length === 0)
|
||||
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||
return <div className="text-[0.6875rem] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,15 +50,15 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
{dedupePlates(plates).map((p, i) => (
|
||||
<span
|
||||
key={`${p.plate}-${p.direction}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[11px]"
|
||||
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
||||
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
||||
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-[9px] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
||||
<span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
|
||||
{typeof p.confidence === "number" && (
|
||||
<span className="text-[10px] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
|
||||
<span className="text-[0.625rem] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
@@ -81,7 +81,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className={`text-[9px] uppercase tracking-wider ${
|
||||
className={`text-[0.5625rem] uppercase tracking-wider ${
|
||||
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
||||
}`}
|
||||
>
|
||||
@@ -100,8 +100,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
||||
>
|
||||
<span className="text-lg leading-none text-term-amber">⚠</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||
<span className="px-1 text-[0.5625rem] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function StatusDot() {
|
||||
const { t } = useTranslation();
|
||||
const status = useLiveStore((s) => s.status);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
|
||||
/>
|
||||
|
||||
@@ -95,9 +95,9 @@ export function displayIdentity(e: LedgerEvent): string {
|
||||
return e.subscriberLabel ?? e.identity ?? "—";
|
||||
}
|
||||
|
||||
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps
|
||||
* the time/label/identity/index columns aligned across rows; the detail line lives in
|
||||
* its own row, indented under the identity column. */
|
||||
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the
|
||||
* time/label/#index columns aligned across rows; the identity, plate, badges and reason flow
|
||||
* inline in the middle column and wrap there only when they run out of width. */
|
||||
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const style = eventStyleFor(e);
|
||||
@@ -110,52 +110,44 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
||||
const reason = renderReason(p, t);
|
||||
const amount = paymentSummary(p);
|
||||
const badges = eventBadges(p);
|
||||
const via = viaKey(p);
|
||||
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
||||
const showDetail = detail != null || badges.length > 0 || via != null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(e)}
|
||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-start gap-x-3 border-b border-term-border/50 px-1 py-1 text-left text-[0.75rem] tabular-nums hover:bg-term-panel-2 ${
|
||||
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||
<span className="py-px text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`py-px shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||
{/* Identity + plate + detail all flow in ONE wrapping line — they fill the available
|
||||
width and only wrap to a second line when this cell actually runs out of room (no
|
||||
forced second row). Keeps time/label/#index in their columns. */}
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 py-px">
|
||||
<span className="break-all text-term-text">{displayIdentity(e)}</span>
|
||||
{e.plate && (
|
||||
<span
|
||||
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
||||
className="shrink-0 rounded border border-term-border px-1 text-[0.6875rem] font-semibold tracking-wide text-term-amber"
|
||||
title={t("booth.plateTitle")}
|
||||
>
|
||||
{e.plate}
|
||||
</span>
|
||||
)}
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="shrink-0 rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
{detail && (
|
||||
<span className={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-term-muted">#{e.index}</span>
|
||||
{showDetail && (
|
||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
{via && (
|
||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
||||
{t(via)}
|
||||
</span>
|
||||
)}
|
||||
{detail && (
|
||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="py-px text-term-muted">#{e.index}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -163,8 +155,8 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
||||
/** One label/value line in the event-detail modal. */
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[0.75rem]">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className="min-w-0 break-words text-term-text">{children}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -203,19 +195,19 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
||||
{(reason || money) && (
|
||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||
<div className={`mt-1 text-[0.8125rem] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||
{reason ?? money}
|
||||
</div>
|
||||
)}
|
||||
{!reason && !money && isAnomaly && (
|
||||
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||
<div className="mt-1 text-[0.8125rem] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||
)}
|
||||
{badges.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
@@ -235,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
(the SUBSESS-… session key) for traceability against the ledger. */}
|
||||
{e.subscriberLabel && e.identity && (
|
||||
<DetailRow label={t("booth.edOccurrence")}>
|
||||
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
||||
<code className="text-[0.6875rem] text-term-muted">{e.identity}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{money && (
|
||||
@@ -257,7 +249,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
)}
|
||||
{tariffVersionId && (
|
||||
<DetailRow label={t("booth.edTariffVersion")}>
|
||||
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
||||
<code className="text-[0.6875rem] text-term-muted">{tariffVersionId}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
</div>
|
||||
@@ -265,7 +257,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
{/* The entry/exit evidence images for this session's identity. */}
|
||||
{e.identity && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||
<div className="mb-1.5 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||
<SnapshotStrip identity={e.identity} />
|
||||
</div>
|
||||
)}
|
||||
@@ -275,28 +267,28 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
operator's; tucking them behind a disclosure keeps the common view clean
|
||||
while preserving the tamper-evidence trail on demand. */}
|
||||
<details className="rounded-term border border-term-border bg-term-panel-2">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-[0.6875rem] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
{t("booth.edAuditData")}
|
||||
</summary>
|
||||
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
||||
<DetailRow label={t("booth.edSignature")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
||||
<code className="break-all text-[0.6875rem] text-term-muted">{e.signature}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edKeyId")}>
|
||||
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
||||
<code className="text-[0.6875rem] text-term-muted">{e.keyId}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edPrevHash")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||
<code className="break-all text-[0.6875rem] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||
</DetailRow>
|
||||
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<div className="mb-1.5 mt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("booth.edRawPayload")}
|
||||
</div>
|
||||
{p && Object.keys(p).length > 0 ? (
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[0.6875rem] text-term-text">
|
||||
{JSON.stringify(p, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||
<div className="text-[0.75rem] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
Reference in New Issue
Block a user