Compare commits
3 Commits
38481f105f
...
cce99aadfd
| Author | SHA1 | Date | |
|---|---|---|---|
| cce99aadfd | |||
| f706726eeb | |||
| 6734e9815e |
@@ -86,6 +86,18 @@ export interface LaneStatusEvent {
|
|||||||
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate
|
||||||
|
* recognition is async/advisory (a vision round-trip off the snapshot), so it lands a
|
||||||
|
* moment after the signed event — too late for the event's own WS push to carry it. This
|
||||||
|
* notifies the booth so it can fill in the plate badge on the already-rendered feed row /
|
||||||
|
* active session in place, no refresh. Advisory; never touches the signed ledger. See
|
||||||
|
* snapshot.ts (recognizePlate) + event-enrich.ts. */
|
||||||
|
export interface PlateRecognizedEvent {
|
||||||
|
readonly identity: string; // the session identity the plate is tied to
|
||||||
|
readonly plate: string; // normalized plate text (trimmed, upper)
|
||||||
|
readonly direction: "entry" | "exit";
|
||||||
|
}
|
||||||
|
|
||||||
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||||
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||||
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||||
@@ -168,6 +180,16 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("lane-presence", cb);
|
this.on("lane-presence", cb);
|
||||||
return () => this.off("lane-presence", cb);
|
return () => this.off("lane-presence", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted when an async plate recognition completes for a session (after its event
|
||||||
|
* already shipped). Lets the booth backfill the plate badge in place. Advisory only. */
|
||||||
|
emitPlateRecognized(event: PlateRecognizedEvent): void {
|
||||||
|
this.emit("plate-recognized", event);
|
||||||
|
}
|
||||||
|
onPlateRecognized(cb: (event: PlateRecognizedEvent) => void): () => void {
|
||||||
|
this.on("plate-recognized", cb);
|
||||||
|
return () => this.off("plate-recognized", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ interface ThemeBody {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI font scale: percent of base, clamped to [80, 160] in steps of 10. Integer percent.
|
||||||
|
const FONT_SCALE_MIN = 80;
|
||||||
|
const FONT_SCALE_MAX = 160;
|
||||||
|
interface FontScaleBody {
|
||||||
|
fontScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
||||||
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
|
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
|
||||||
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
||||||
@@ -67,6 +74,7 @@ function sessionView(
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
language: string;
|
language: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
fontScale: number;
|
||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
},
|
},
|
||||||
@@ -81,6 +89,7 @@ function sessionView(
|
|||||||
permissions,
|
permissions,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
|
fontScale: user.fontScale,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
};
|
};
|
||||||
@@ -171,6 +180,23 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Change MY own UI font scale (any signed-in user). Percent of base, clamped to
|
||||||
|
// [80, 160] in steps of 10. Persisted like `theme`, restored on the next login.
|
||||||
|
app.put<{ Body: FontScaleBody }>(
|
||||||
|
"/api/auth/font-scale",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const raw = req.body?.fontScale;
|
||||||
|
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||||
|
return reply.code(400).send({ error: "fontScale must be a number" });
|
||||||
|
}
|
||||||
|
// Snap to a 10-step and clamp to the allowed band (defensive — the UI already does).
|
||||||
|
const fontScale = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(raw / 10) * 10));
|
||||||
|
await db.update(users).set({ fontScale }).where(eq(users.id, req.user.sub)).run();
|
||||||
|
return { fontScale };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
||||||
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
||||||
app.put<{ Body: ProfileBody }>(
|
app.put<{ Body: ProfileBody }>(
|
||||||
|
|||||||
@@ -128,3 +128,57 @@ describe("PUT /api/auth/password (self-service)", () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/auth/font-scale (self-service)", () => {
|
||||||
|
it("persists a valid scale and returns it on the next session", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f1", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: 120 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().fontScale).toBe(120);
|
||||||
|
// Persisted to the caller's row…
|
||||||
|
expect(db.select().from(users).where(eq(users.username, "f1")).get()?.fontScale).toBe(120);
|
||||||
|
// …and surfaced on /me (the session bootstrap).
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().fontScale).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps + snaps out-of-band / off-step values", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f2", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const tooBig = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: 999 },
|
||||||
|
});
|
||||||
|
expect(tooBig.json().fontScale).toBe(160); // clamped to max
|
||||||
|
const offStep = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: 113 },
|
||||||
|
});
|
||||||
|
expect(offStep.json().fontScale).toBe(110); // snapped to the 10-step
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-numeric scale (400)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f3", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: "big" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 100 for a fresh user", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f4", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().fontScale).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { roleHasPermissions } from "../auth.js";
|
||||||
import { deviceEvents, type LaneStatusEvent, type LanePresenceEvent } from "../device-events.js";
|
import {
|
||||||
|
deviceEvents,
|
||||||
|
type LaneStatusEvent,
|
||||||
|
type LanePresenceEvent,
|
||||||
|
type PlateRecognizedEvent,
|
||||||
|
} from "../device-events.js";
|
||||||
import { enrichEvent } from "../event-enrich.js";
|
import { enrichEvent } from "../event-enrich.js";
|
||||||
import type { DeviceMonitor } from "../device-monitor.js";
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
import type { LaneStatus } from "../lane-status.js";
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
@@ -65,7 +70,8 @@ type OutMsg =
|
|||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: unknown }
|
| { kind: "device-status"; event: unknown }
|
||||||
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||||
| { kind: "lane-presence"; radar: LanePresenceEvent };
|
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
||||||
|
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -136,6 +142,10 @@ export async function wsRoutes(
|
|||||||
const offPresence = deviceEvents.onLanePresence((radar) => {
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
send({ kind: "lane-presence", radar });
|
send({ kind: "lane-presence", radar });
|
||||||
});
|
});
|
||||||
|
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
||||||
|
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
||||||
|
send({ kind: "plate-recognized", plate });
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
offLedger();
|
offLedger();
|
||||||
@@ -143,6 +153,7 @@ export async function wsRoutes(
|
|||||||
offDevice();
|
offDevice();
|
||||||
offLane();
|
offLane();
|
||||||
offPresence();
|
offPresence();
|
||||||
|
offPlate();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 --",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/
|
|||||||
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
||||||
@@ -138,6 +139,9 @@ async function recognizePlate(
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
|
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
|
||||||
|
// The session's entry/exit event already shipped without this (async) plate — tell the
|
||||||
|
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
|
||||||
|
deviceEvents.emitPlateRecognized({ identity, plate, direction });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +63,15 @@ 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) {
|
||||||
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase();
|
// Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits.
|
||||||
|
const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase();
|
||||||
if (!hay.includes(q)) return false;
|
if (!hay.includes(q)) return false;
|
||||||
}
|
}
|
||||||
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") },
|
||||||
@@ -119,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>
|
||||||
@@ -128,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>
|
||||||
|
|
||||||
@@ -142,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>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fetchMe, type SessionUser } from "./api.js";
|
|||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
|
||||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
@@ -24,15 +24,17 @@ export function App() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language + theme whenever they resolve/
|
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||||
// change (login, bootstrap, or a toggle). Albanian + dark are the defaults before
|
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
|
||||||
// auth resolves; on logout, fall back to dark so the Login screen is consistent.
|
// before auth resolves; on logout, fall back so the Login screen is consistent.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setLanguage(user.language);
|
setLanguage(user.language);
|
||||||
applyTheme(user.theme);
|
applyTheme(user.theme);
|
||||||
|
applyFontScale(user.fontScale);
|
||||||
} else {
|
} else {
|
||||||
applyTheme("dark");
|
applyTheme("dark");
|
||||||
|
applyFontScale(100);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -207,17 +207,17 @@ export function BoothScreen() {
|
|||||||
|
|
||||||
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
||||||
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
||||||
// subscriber label, and any advisory plate on the payload.
|
// subscriber label, and the enriched advisory plate (`e.plate` — the displayed field;
|
||||||
|
// the plate is NOT in the signed payload, so `payload.plate` would never match).
|
||||||
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;
|
||||||
}
|
}
|
||||||
if (fq) {
|
if (fq) {
|
||||||
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase();
|
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.plate ?? ""}`.toLowerCase();
|
||||||
if (!hay.includes(fq)) return false;
|
if (!hay.includes(fq)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -230,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") },
|
||||||
@@ -271,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>
|
||||||
@@ -287,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>
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export interface SessionUser {
|
|||||||
language: Lang;
|
language: Lang;
|
||||||
/** Preferred UI theme (loaded from the server on login). */
|
/** Preferred UI theme (loaded from the server on login). */
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
/** Preferred UI font scale, percent of base (100 = base; clamped 80–160). */
|
||||||
|
fontScale: number;
|
||||||
/** Optional display name (profile metadata); null if unset. */
|
/** Optional display name (profile metadata); null if unset. */
|
||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
/** Optional contact email (profile metadata); null if unset. */
|
/** Optional contact email (profile metadata); null if unset. */
|
||||||
@@ -105,6 +107,16 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
|
|||||||
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Allowed font-scale band (percent of base) + step. The header control clamps to these. */
|
||||||
|
export const FONT_SCALE_MIN = 80;
|
||||||
|
export const FONT_SCALE_MAX = 160;
|
||||||
|
export const FONT_SCALE_STEP = 10;
|
||||||
|
|
||||||
|
/** Persist the current user's UI font scale (percent; restored on next login). */
|
||||||
|
export function setFontScalePref(fontScale: number): Promise<{ fontScale: number }> {
|
||||||
|
return apiFetch("/api/auth/font-scale", { method: "PUT", body: JSON.stringify({ fontScale }) });
|
||||||
|
}
|
||||||
|
|
||||||
/** Edit MY own profile (display name / email). Returns the refreshed session.
|
/** Edit MY own profile (display name / email). Returns the refreshed session.
|
||||||
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
||||||
export function updateMyProfile(patch: {
|
export function updateMyProfile(patch: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+11
-11
@@ -14,6 +14,9 @@ export const en: Catalog = {
|
|||||||
themeDark: "dark",
|
themeDark: "dark",
|
||||||
themeLight: "light",
|
themeLight: "light",
|
||||||
theme: "Theme",
|
theme: "Theme",
|
||||||
|
fontSmaller: "Smaller text",
|
||||||
|
fontLarger: "Larger text",
|
||||||
|
fontSize: "Text size",
|
||||||
today: "Today",
|
today: "Today",
|
||||||
yesterday: "Yesterday",
|
yesterday: "Yesterday",
|
||||||
months: [
|
months: [
|
||||||
@@ -118,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",
|
||||||
@@ -133,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.",
|
||||||
@@ -141,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",
|
||||||
@@ -699,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:",
|
||||||
@@ -736,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",
|
||||||
@@ -754,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",
|
||||||
|
|||||||
+12
-12
@@ -14,6 +14,9 @@ export const sq = {
|
|||||||
themeDark: "errët",
|
themeDark: "errët",
|
||||||
themeLight: "çelët",
|
themeLight: "çelët",
|
||||||
theme: "Tema",
|
theme: "Tema",
|
||||||
|
fontSmaller: "Zvogëlo tekstin",
|
||||||
|
fontLarger: "Rrit tekstin",
|
||||||
|
fontSize: "Madhësia e tekstit",
|
||||||
today: "Sot",
|
today: "Sot",
|
||||||
yesterday: "Dje",
|
yesterday: "Dje",
|
||||||
// Month names (index 0 = January) — kept in the catalog because the appliance's
|
// Month names (index 0 = January) — kept in the catalog because the appliance's
|
||||||
@@ -120,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",
|
||||||
@@ -135,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.",
|
||||||
@@ -143,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",
|
||||||
@@ -684,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",
|
||||||
@@ -712,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:",
|
||||||
@@ -749,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).
|
||||||
@@ -769,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",
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ interface LiveState {
|
|||||||
setLanes: (l: LaneStatus) => void;
|
setLanes: (l: LaneStatus) => void;
|
||||||
/** Set lane radar presence (WS hello + each lane-presence push). */
|
/** Set lane radar presence (WS hello + each lane-presence push). */
|
||||||
setRadar: (r: LanePresence) => void;
|
setRadar: (r: LanePresence) => void;
|
||||||
|
/** Backfill the enriched plate on every feed event matching `identity` (a late async
|
||||||
|
* recognition that landed after the event's own push). No-op if no row matches. */
|
||||||
|
patchPlate: (identity: string, plate: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,5 +83,10 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||||
setLanes: (lanes) => set({ lanes }),
|
setLanes: (lanes) => set({ lanes }),
|
||||||
setRadar: (radar) => set({ radar }),
|
setRadar: (radar) => set({ radar }),
|
||||||
|
patchPlate: (identity, plate) =>
|
||||||
|
set((s) => {
|
||||||
|
if (!s.feed.some((e) => e.identity === identity && !e.plate)) return s; // nothing to fill
|
||||||
|
return { feed: s.feed.map((e) => (e.identity === identity && !e.plate ? { ...e, plate } : e)) };
|
||||||
|
}),
|
||||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
|
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import type { Theme } from "../api.js";
|
import type { Theme } from "../api.js";
|
||||||
|
import { FONT_SCALE_MAX, FONT_SCALE_MIN } from "../api.js";
|
||||||
|
|
||||||
// Theme application. The whole UI reads colour through the --color-term-* tokens;
|
// Theme + font-scale application. The whole UI reads colour through the --color-term-*
|
||||||
// the light palette lives in index.css under `html.theme-light`. Applying a theme is
|
// tokens; the light palette lives in index.css under `html.theme-light`. Applying a theme
|
||||||
// just toggling that class on <html>. The active theme is the LOGGED-IN USER's stored
|
// is just toggling that class on <html>. Both are the LOGGED-IN USER's stored preferences
|
||||||
// preference (users.theme), applied via applyTheme() after auth resolves — mirroring
|
// (users.theme / users.font_scale), applied after auth resolves — mirroring how language
|
||||||
// how language works. Dark is the default before auth resolves. Printed tickets are
|
// works. Defaults (dark, 100%) apply before auth resolves. Printed tickets are unaffected.
|
||||||
// unaffected (always Albanian, dark-agnostic).
|
|
||||||
|
|
||||||
/** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the
|
/** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the
|
||||||
* class (the base tokens). No-op-safe to call repeatedly. */
|
* class (the base tokens). No-op-safe to call repeatedly. */
|
||||||
export function applyTheme(theme: Theme): void {
|
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 by setting the ROOT font-size (percent). The app's text is sized in
|
||||||
|
* rem (the `text-[…rem]` utilities + the .label/.input/.hint/.btn component classes all
|
||||||
|
* derive from the root), so only TEXT scales — viewport-locked layout (h-screen frame,
|
||||||
|
* max-h-[90vh] modals, vh units) is unaffected, so headers/footers never clip; taller
|
||||||
|
* content just scrolls its own container. NOT `zoom` (which scaled those vh boxes too and
|
||||||
|
* pushed modal chrome out of view). Clamped to the band; no-op-safe to call repeatedly. */
|
||||||
|
export function applyFontScale(pct: number): void {
|
||||||
|
const clamped = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(pct)));
|
||||||
|
// 100% = the browser's 16px root. The app's rem units scale off this.
|
||||||
|
document.documentElement.style.fontSize = clamped === 100 ? "" : `${clamped}%`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ type WsMessage =
|
|||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus }
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
| { kind: "lane-status"; lanes: LaneStatus }
|
| { kind: "lane-status"; lanes: LaneStatus }
|
||||||
| { kind: "lane-presence"; radar: LanePresence };
|
| { kind: "lane-presence"; radar: LanePresence }
|
||||||
|
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
||||||
|
|
||||||
|
|
||||||
export function useLiveFeed(): void {
|
export function useLiveFeed(): void {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar } = useLiveStore();
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||||
|
useLiveStore();
|
||||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<WebSocket | null>(null);
|
||||||
@@ -64,6 +66,11 @@ export function useLiveFeed(): void {
|
|||||||
setLanes(msg.lanes);
|
setLanes(msg.lanes);
|
||||||
} else if (msg.kind === "lane-presence") {
|
} else if (msg.kind === "lane-presence") {
|
||||||
setRadar(msg.radar);
|
setRadar(msg.radar);
|
||||||
|
} else if (msg.kind === "plate-recognized") {
|
||||||
|
// Backfill the badge on the already-rendered feed row, and refetch the
|
||||||
|
// Query-owned active-sessions list (re-runs enrichEvents → the now-written plate).
|
||||||
|
patchPlate(msg.plate.identity, msg.plate.plate);
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
} else if (msg.kind === "ledger") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
|
|||||||
+66
-14
@@ -10,11 +10,23 @@ import { lazy, Suspense, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
import { can, closeShift, fetchShiftReport, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
|
import {
|
||||||
|
can,
|
||||||
|
closeShift,
|
||||||
|
fetchShiftReport,
|
||||||
|
logout,
|
||||||
|
openShift,
|
||||||
|
setLanguagePref,
|
||||||
|
setThemePref,
|
||||||
|
setFontScalePref,
|
||||||
|
FONT_SCALE_MIN,
|
||||||
|
FONT_SCALE_MAX,
|
||||||
|
FONT_SCALE_STEP,
|
||||||
|
} from "./api.js";
|
||||||
import { qk, queryClient } from "./lib/query.js";
|
import { qk, queryClient } from "./lib/query.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||||
@@ -56,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>
|
||||||
@@ -70,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>
|
||||||
@@ -147,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}
|
||||||
@@ -193,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}
|
||||||
@@ -210,6 +222,37 @@ function ThemeToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header font-size control: A−/value/A+ scaling the whole UI (root font-size). Persisted
|
||||||
|
* to the user profile like the theme, restored on next login. Local `active` state seeded
|
||||||
|
* from the prop (the router context doesn't re-render on setUser); App's effect keeps the
|
||||||
|
* DOM in sync with the persisted user on (re)login. */
|
||||||
|
function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: SessionUser | null) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [active, setActive] = useState<number>(user.fontScale);
|
||||||
|
function step(delta: number) {
|
||||||
|
const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, active + delta));
|
||||||
|
if (next === active) return;
|
||||||
|
setActive(next);
|
||||||
|
applyFontScale(next); // instant UI
|
||||||
|
setUser({ ...user, fontScale: next });
|
||||||
|
void setFontScalePref(next).catch(() => {
|
||||||
|
/* non-fatal — the choice still applies this session */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||||
|
<button type="button" className={btn} onClick={() => step(-FONT_SCALE_STEP)} disabled={active <= FONT_SCALE_MIN} title={t("common.fontSmaller")} aria-label={t("common.fontSmaller")}>
|
||||||
|
A−
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[2.5rem] text-center text-term-muted" title={t("common.fontSize")}>{active}%</span>
|
||||||
|
<button type="button" className={btn} onClick={() => step(FONT_SCALE_STEP)} disabled={active >= FONT_SCALE_MAX} title={t("common.fontLarger")} aria-label={t("common.fontLarger")}>
|
||||||
|
A+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
* Header shift control — the site-wide single-open shift expressed as one button:
|
||||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||||
@@ -273,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}
|
||||||
@@ -315,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>
|
||||||
@@ -327,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>
|
||||||
</>
|
</>
|
||||||
@@ -353,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -399,12 +450,13 @@ function RootLayout() {
|
|||||||
{user && <ShiftButton />}
|
{user && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
|
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
<StatusDot />
|
||||||
{user && (
|
{user && (
|
||||||
<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>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Per-user UI font scale (PERCENT of base; 100 = base). Persisted like `theme`/`language`
|
||||||
|
-- so an operator's chosen text size is restored on the next login from any booth. Additive
|
||||||
|
-- ALTER ADD COLUMN — backward-compatible; existing users default to 100 (base). The client
|
||||||
|
-- clamps to 80–160 in steps of 10. Printed tickets are unaffected (server-rendered).
|
||||||
|
ALTER TABLE `users` ADD `font_scale` integer DEFAULT 100 NOT NULL;
|
||||||
@@ -99,6 +99,13 @@
|
|||||||
"when": 1781885600000,
|
"when": 1781885600000,
|
||||||
"tag": "0013_anpr_entry_toggle",
|
"tag": "0013_anpr_entry_toggle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885700000,
|
||||||
|
"tag": "0014_user_font_scale",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,11 @@ export const users = sqliteTable("users", {
|
|||||||
theme: text("theme", { enum: ["dark", "light"] })
|
theme: text("theme", { enum: ["dark", "light"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("dark"),
|
.default("dark"),
|
||||||
|
// Preferred UI font scale (PERCENT of base, e.g. 100 = base, 120 = 20% larger).
|
||||||
|
// Persisted like `theme` (read on login, restored from any booth). Integer percent
|
||||||
|
// avoids float drift; the client clamps to 80–160 in steps of 10. Printed tickets are
|
||||||
|
// unaffected (server-rendered).
|
||||||
|
fontScale: integer("font_scale").notNull().default(100),
|
||||||
// Optional operator profile metadata — display name + contact details. All
|
// Optional operator profile metadata — display name + contact details. All
|
||||||
// nullable; only username/password/role are required to create a user. fullName
|
// nullable; only username/password/role are required to create a user. fullName
|
||||||
// (when set) is the human label for audit/Z-report display.
|
// (when set) is the human label for audit/Z-report display.
|
||||||
|
|||||||
+32
@@ -1810,3 +1810,35 @@ never disagree. A test (lane-presence.test.ts) caught a real bug: the first cut
|
|||||||
`relayForPresence`, so the EXIT lane never resolved (entry-gated) and never blinked —
|
`relayForPresence`, so the EXIT lane never resolved (entry-gated) and never blinked —
|
||||||
`presenceLaneOf` fixes it. Full workspace build/lint/test green (185 server tests). Updated
|
`presenceLaneOf` fixes it. Full workspace build/lint/test green (185 server tests). Updated
|
||||||
[[button-light-indicator]] (new "On-screen twin" section).
|
[[button-light-indicator]] (new "On-screen twin" section).
|
||||||
|
|
||||||
|
## [2026-06-28] fix+feat | Booth feed plate backfill, plate search, + per-user font scale
|
||||||
|
Three booth fixes + one prefs feature:
|
||||||
|
- **Plate not showing until refresh (fixed).** Plate recognition is async/advisory
|
||||||
|
(snapshot.ts recognizePlate → a kind:"read" device_event keyed by session identity), so it
|
||||||
|
lands AFTER the entry/exit event already shipped over the WS without a plate. Added a
|
||||||
|
`plate-recognized` bus event (device-events.ts) emitted when the read is written; ws.ts
|
||||||
|
forwards it; the client `patchPlate(identity,plate)` (live-store) backfills the already-
|
||||||
|
rendered feed row in place and invalidates the Query-owned active-sessions list. No refresh.
|
||||||
|
- **Plate search didn't filter (fixed).** Both the live-feed (BoothScreen) and active-sessions
|
||||||
|
(ActiveSessions) search haystacks used the wrong field — the plate is the ENRICHED top-level
|
||||||
|
`e.plate`/`s.plate` (set by enrichEvent), not `payload.plate` (plate is unsigned, never in the
|
||||||
|
payload). Switched the haystacks to the displayed field.
|
||||||
|
- **Per-user font scale (new).** A header A−/value/A+ control scales the root font-size app-wide
|
||||||
|
(rem-based tokens scale proportionally), persisted on `users.font_scale` (migration
|
||||||
|
0014_user_font_scale, percent 100=base, clamp 80–160 step 10) and restored on login — cloning
|
||||||
|
the theme-pref pattern end to end (PUT /api/auth/font-scale, sessionView, setFontScalePref,
|
||||||
|
applyFontScale in App). i18n sq+en. Tests: 4 font-scale auth-route cases (persist+/me, clamp/
|
||||||
|
snap, 400, default). Full workspace build/lint/test green (189 server tests).
|
||||||
|
|
||||||
|
## [2026-06-28] fix | Font scale: rem-based root scaling (CSS `zoom` broke modal/footer layout)
|
||||||
|
The first cut of the per-user font scale used CSS `zoom` on the root so it would scale the app's
|
||||||
|
px-pinned type (text-[12px] etc.). But `zoom` scales the WHOLE box model including viewport-locked
|
||||||
|
containers — the h-screen app frame and max-h-[90vh] modals — so at 130% they overflowed the viewport
|
||||||
|
and modal headers/footers were pushed out of view (user had to scroll, big-modal chrome hidden).
|
||||||
|
Reworked to the correct fix: converted ALL `text-[Npx]` font utilities to rem across the web app
|
||||||
|
(~230 sites in 25 .tsx files + the .label/.hint/.btn component classes + body in index.css; 16px root
|
||||||
|
→ 12px=0.75rem etc., so 100% is visually identical), and applyFontScale now sets the ROOT font-size
|
||||||
|
(percent) instead of zoom. Only TEXT scales; vh/h-screen layout stays viewport-locked, so modals cap
|
||||||
|
at 90vh and scroll their own body — chrome never clips. Verified with Playwright: at 130% root, sample
|
||||||
|
text 12px→15.6px while the h-screen frame stayed exactly viewport-height and 90vh resolved unchanged.
|
||||||
|
Full workspace build/lint/test green.
|
||||||
|
|||||||
Reference in New Issue
Block a user