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