feat(web): i18n with react-i18next — Albanian default, English second
Add react-i18next with two key-parity-checked catalogs (sq default/fallback, en). Active language driven by the logged-in user's stored preference (applied after /me resolves); SQ/EN toggle in the header persists via PUT /api/auth/language. Translate the booth (screen, pay/exit modal, active sessions, snapshots, status), Login, ShiftControl, SiteSettings, PermitManager, TariffComposer. SetupWizard deferred (its content is server-provided; needs backend catalog i18n).
This commit is contained in:
@@ -17,8 +17,10 @@
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"i18next": "^26.3.1",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
@@ -14,13 +15,14 @@ import { Panel } from "./ui/Panel.js";
|
||||
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
||||
// See wiki/concepts/booth-exit-flow.md.
|
||||
|
||||
function statusBadge(s: ActiveSession): { label: string; cls: string } {
|
||||
if (!s.open && s.withinGrace) return { label: "exiting", cls: "text-term-cyan" };
|
||||
if (s.paidAt) return { label: "paid", cls: "text-term-green" };
|
||||
return { label: "unpaid", cls: "text-term-amber" };
|
||||
function statusBadge(s: ActiveSession): { key: string; cls: string } {
|
||||
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
||||
}
|
||||
|
||||
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.activeSessions,
|
||||
@@ -48,7 +50,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
setReopenMsg({
|
||||
id: s.identity,
|
||||
ok: r.opened,
|
||||
text: r.opened ? "barrier opened" : r.reason ?? "open manually",
|
||||
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
|
||||
});
|
||||
} catch (e) {
|
||||
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
|
||||
@@ -57,13 +59,17 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Active sessions"
|
||||
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{sessions.length} inside</span>}
|
||||
title={t("booth.activeSessions")}
|
||||
right={
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||
{sessions.length} {t("booth.insideCount")}
|
||||
</span>
|
||||
}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-term-muted">{isLoading ? "loading…" : "no active sessions."}</div>
|
||||
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const badge = statusBadge(s);
|
||||
@@ -77,12 +83,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
type="button"
|
||||
onClick={() => onPick(s.identity)}
|
||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||
title="Open pay / exit"
|
||||
title={t("booth.openPayExit")}
|
||||
>
|
||||
<span className="text-term-text">{s.identity}</span>
|
||||
<span className="text-term-muted">in {formatTime(s.enteredAt)}</span>
|
||||
<span className="text-term-muted">
|
||||
{t("booth.inAt")} {formatTime(s.enteredAt)}
|
||||
</span>
|
||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{badge.label}</span>
|
||||
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
||||
</button>
|
||||
|
||||
{/* Open barrier — PAID sessions only (no payment, no button). */}
|
||||
@@ -92,9 +100,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
disabled={reopen.isPending}
|
||||
onClick={() => handleReopen(s)}
|
||||
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
|
||||
title="Human-intervention barrier open (audited)"
|
||||
title={t("booth.openBarrierTitle")}
|
||||
>
|
||||
Open barrier
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-[88px] shrink-0" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterProvider } from "@tanstack/react-router";
|
||||
import { fetchMe, type SessionUser } from "./api.js";
|
||||
import { Login } from "./Login.js";
|
||||
import { queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { router } from "./router.js";
|
||||
|
||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||
@@ -22,6 +23,12 @@ export function App() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Apply the signed-in user's preferred language whenever it resolves/changes
|
||||
// (login, bootstrap, or a toggle). Albanian is the default before auth resolves.
|
||||
useEffect(() => {
|
||||
if (user) setLanguage(user.language);
|
||||
}, [user]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
type Phase = "review" | "paying" | "finishing" | "done" | "error";
|
||||
|
||||
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
|
||||
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
|
||||
@@ -52,13 +54,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
setPhase("finishing");
|
||||
if (voucher) {
|
||||
const r = await printVoucher(identity);
|
||||
setResult(`Exit voucher printed on ${r.printedBy}. Customer self-exits at the exit.`);
|
||||
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
||||
} else {
|
||||
const r = await boothExit(identity);
|
||||
setResult(
|
||||
r.opened
|
||||
? "Paid — barrier opened. Car may exit."
|
||||
: `Paid and exit recorded, but the barrier did not open: ${r.reason ?? "open manually"}.`,
|
||||
? t("pay.paidBarrierOpened")
|
||||
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }),
|
||||
);
|
||||
}
|
||||
// Refresh the live views.
|
||||
@@ -81,25 +83,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
>
|
||||
<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">
|
||||
Ticket {identity}
|
||||
{t("pay.ticket")} {identity}
|
||||
</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={t("common.close")}>
|
||||
✕
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{session.isLoading && <div className="text-term-muted">looking up…</div>}
|
||||
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
|
||||
|
||||
{s && !s.found && (
|
||||
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
|
||||
No session found for this ticket.
|
||||
{t("pay.noSessionFound")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && s.found && !s.open && (
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
This session is already closed (exited {formatTime(s.exitedAt)}).
|
||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -107,25 +109,28 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<>
|
||||
{/* Session figures */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label="Entry" value={formatTime(s.enteredAt)} />
|
||||
<Row label="Now" value={formatTime(new Date().toISOString())} />
|
||||
<Row label="Duration" value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"} />
|
||||
<Row label={t("pay.entry")} value={formatTime(s.enteredAt)} />
|
||||
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
||||
<Row
|
||||
label="Status"
|
||||
value={alreadyPaid ? "PAID" : "UNPAID"}
|
||||
label={t("pay.duration")}
|
||||
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.statusLabel")}
|
||||
value={alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
||||
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<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">Total</span>
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.total")}</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
{s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? "paid"
|
||||
: "no tariff"}
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -137,19 +142,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
{/* Tender */}
|
||||
{canPay && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">Tender</span>
|
||||
{(["cash", "card"] as const).map((t) => (
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
{(["cash", "card"] as const).map((tn) => (
|
||||
<button
|
||||
key={t}
|
||||
key={tn}
|
||||
type="button"
|
||||
onClick={() => setTender(t)}
|
||||
onClick={() => setTender(tn)}
|
||||
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
|
||||
tender === t
|
||||
tender === tn
|
||||
? "border-term-amber text-term-amber"
|
||||
: "border-term-border text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
{t(`pay.${tn}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -162,8 +167,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
checked={voucher}
|
||||
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
|
||||
/>
|
||||
Printo biletë dalje
|
||||
<span className="text-term-muted">(customer self-exits at the exit)</span>
|
||||
{t("pay.printExitVoucher")}
|
||||
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
@@ -181,7 +186,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
onClick={onClose}
|
||||
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
|
||||
>
|
||||
Close
|
||||
{t("common.close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
@@ -190,7 +195,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
onClick={onClose}
|
||||
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -199,18 +204,18 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{phase === "paying"
|
||||
? "taking payment…"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? "printing voucher…"
|
||||
: "opening…"
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
? voucher
|
||||
? "Print voucher"
|
||||
: "Open barrier"
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? "Pay + print voucher"
|
||||
: "Pay + open barrier"}
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
@@ -13,17 +14,18 @@ import { ActiveSessions } from "./ActiveSessions.js";
|
||||
// the authoritative numbers; the WS-fed live store overlays real-time updates so
|
||||
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
|
||||
|
||||
/** Per-event-type display: label + accent colour for the ticker. */
|
||||
const EVENT_STYLE: Record<string, { label: string; color: string }> = {
|
||||
vehicle_entry: { label: "ENTRY", color: "text-term-green" },
|
||||
vehicle_exit: { label: "EXIT", color: "text-term-red" },
|
||||
payment: { label: "PAY", color: "text-term-cyan" },
|
||||
void: { label: "VOID", color: "text-term-amber" },
|
||||
barrier_open_command: { label: "OPEN→", color: "text-term-muted" },
|
||||
barrier_open_observed: { label: "OPEN✓", color: "text-term-muted" },
|
||||
shift_open: { label: "SHIFT+", color: "text-term-amber" },
|
||||
shift_z_report: { label: "SHIFT Z", color: "text-term-amber" },
|
||||
anomaly: { label: "ANOMALY", color: "text-term-red" },
|
||||
/** Per-event-type display: i18n label key + accent colour for the ticker. */
|
||||
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
||||
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
|
||||
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
|
||||
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
|
||||
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
|
||||
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
|
||||
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
|
||||
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
||||
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||
};
|
||||
|
||||
function hhmmss(iso: string): string {
|
||||
@@ -33,6 +35,7 @@ function hhmmss(iso: string): string {
|
||||
}
|
||||
|
||||
function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
const { t } = useTranslation();
|
||||
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
|
||||
const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
|
||||
return (
|
||||
@@ -40,13 +43,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||
<div className="pb-1 text-term-muted">
|
||||
<div className="text-[11px] uppercase tracking-wider">inside</div>
|
||||
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
|
||||
<div className="text-sm tabular-nums">
|
||||
{occ.capacity == null ? "uncapped" : `of ${occ.capacity}`}
|
||||
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">free</div>
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
||||
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||
{occ.free == null ? "∞" : occ.free}
|
||||
</div>
|
||||
@@ -59,7 +62,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
)}
|
||||
{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">
|
||||
● lot full
|
||||
{t("booth.lotFull")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -67,11 +70,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
}
|
||||
|
||||
function EventRow({ e }: { e: LedgerEvent }) {
|
||||
const style = EVENT_STYLE[e.type] ?? { label: e.type.toUpperCase(), color: "text-term-text" };
|
||||
const { t } = useTranslation();
|
||||
const style = EVENT_STYLE[e.type];
|
||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums">
|
||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`w-20 shrink-0 font-semibold ${style.color}`}>{style.label}</span>
|
||||
<span className={`w-20 shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||
<span className="truncate text-term-text">{e.identity ?? "—"}</span>
|
||||
<span className="ml-auto text-term-muted">#{e.index}</span>
|
||||
</div>
|
||||
@@ -82,6 +87,7 @@ function EventRow({ e }: { e: LedgerEvent }) {
|
||||
* operator types it. Either way, submit opens the pay/exit modal for that id. The
|
||||
* input auto-focuses and re-focuses after a scan so the scanner always lands here. */
|
||||
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
@@ -102,7 +108,7 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Scan or type ticket number…"
|
||||
placeholder={t("booth.scanPlaceholder")}
|
||||
inputMode="numeric"
|
||||
className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
|
||||
/>
|
||||
@@ -110,13 +116,14 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
type="submit"
|
||||
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
|
||||
>
|
||||
Open
|
||||
{t("booth.open")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoothScreen() {
|
||||
const { t } = useTranslation();
|
||||
// Initial load via Query (also the fallback if the WS is briefly down).
|
||||
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
|
||||
@@ -140,18 +147,18 @@ export function BoothScreen() {
|
||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
||||
<div className="lg:col-span-2">
|
||||
<Panel title="Process ticket">
|
||||
<Panel title={t("booth.processTicket")}>
|
||||
<TicketInput onSubmit={setActiveTicket} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* Left column: occupancy gauge above the active-sessions list. */}
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<Panel title="Occupancy" right={<StatusDot />}>
|
||||
<Panel title={t("booth.occupancy")} right={<StatusDot />}>
|
||||
{occ ? (
|
||||
<OccupancyGauge occ={occ} />
|
||||
) : (
|
||||
<div className="text-term-muted">{occQuery.isError ? "occupancy unavailable" : "loading…"}</div>
|
||||
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
|
||||
)}
|
||||
</Panel>
|
||||
<div className="min-h-0 flex-1">
|
||||
@@ -160,15 +167,17 @@ export function BoothScreen() {
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Live feed"
|
||||
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{events.length} events</span>}
|
||||
title={t("booth.liveFeed")}
|
||||
right={
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||
{events.length} {t("booth.events")}
|
||||
</span>
|
||||
}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
{events.length === 0 ? (
|
||||
<div className="text-term-muted">
|
||||
{eventsQuery.isLoading ? "loading…" : "no events yet — entries and exits will stream here."}
|
||||
</div>
|
||||
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
||||
) : (
|
||||
events.map((e) => <EventRow key={e.id} e={e} />)
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { login, type SessionUser } from "./api.js";
|
||||
|
||||
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -22,11 +24,11 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
|
||||
<h1>Parking System</h1>
|
||||
<h1>{t("auth.title")}</h1>
|
||||
<form onSubmit={submit}>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
Username
|
||||
{t("auth.username")}
|
||||
<br />
|
||||
<input
|
||||
value={username}
|
||||
@@ -39,7 +41,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
</div>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
Password
|
||||
{t("auth.password")}
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
@@ -52,7 +54,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
</div>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<button type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
createPermit,
|
||||
@@ -41,6 +42,12 @@ function formFrom(p: Permit): FormState {
|
||||
platesText: p.plates.join(", "),
|
||||
};
|
||||
}
|
||||
const STATUS_KEY: Record<Permit["status"], string> = {
|
||||
active: "permits.statusActive",
|
||||
suspended: "permits.statusSuspended",
|
||||
revoked: "permits.statusRevoked",
|
||||
};
|
||||
|
||||
function toInput(f: FormState): PermitInput {
|
||||
return {
|
||||
holderName: f.holderName.trim() || null,
|
||||
@@ -54,6 +61,7 @@ function toInput(f: FormState): PermitInput {
|
||||
}
|
||||
|
||||
export function PermitManager() {
|
||||
const { t } = useTranslation();
|
||||
const [permits, setPermits] = useState<Permit[] | null>(null);
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
@@ -84,19 +92,19 @@ export function PermitManager() {
|
||||
else if (editing) await updatePermit(editing, toInput(form));
|
||||
setEditing(null);
|
||||
reload();
|
||||
setMsg({ kind: "ok", text: "Permit saved." });
|
||||
setMsg({ kind: "ok", text: t("permits.permitSaved") });
|
||||
} catch (e) {
|
||||
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||
}
|
||||
}
|
||||
async function doRevoke(p: Permit) {
|
||||
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
|
||||
if (!confirm(t("permits.confirmRevoke", { name: p.holderName ?? p.id }))) return;
|
||||
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
async function doDelete(p: Permit) {
|
||||
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
|
||||
if (!confirm(t("permits.confirmDelete", { name: p.holderName ?? p.id }))) return;
|
||||
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
@@ -109,71 +117,71 @@ export function PermitManager() {
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Permits</h2>
|
||||
<h2>{t("permits.title")}</h2>
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{permits.map((p) => (
|
||||
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
||||
<strong>{p.holderName ?? "(unnamed)"}</strong>
|
||||
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
|
||||
<strong>{p.holderName ?? t("permits.unnamed")}</strong>
|
||||
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[p.status])}</span>
|
||||
<span style={{ color: "#666" }}>
|
||||
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
|
||||
{p.credentials.length} cred · {p.plates.length} plate(s)
|
||||
{p.maxConcurrent == null ? t("permits.unbound") : t("permits.car", { count: p.maxConcurrent })} ·{" "}
|
||||
{p.credentials.length} {t("permits.cred")} · {t("permits.plates", { count: p.plates.length })}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => startEdit(p)}>Edit</button>
|
||||
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
|
||||
<button type="button" onClick={() => doDelete(p)}>Delete</button>
|
||||
<button type="button" onClick={() => startEdit(p)}>{t("permits.edit")}</button>
|
||||
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>{t("permits.revoke")}</button>}
|
||||
<button type="button" onClick={() => doDelete(p)}>{t("permits.delete")}</button>
|
||||
</li>
|
||||
))}
|
||||
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
|
||||
{permits.length === 0 && <li style={{ color: "#777" }}>{t("permits.noPermitsYet")}</li>}
|
||||
</ul>
|
||||
|
||||
{editing == null ? (
|
||||
<button type="button" onClick={startNew}>+ Add permit</button>
|
||||
<button type="button" onClick={startNew}>{t("permits.addPermit")}</button>
|
||||
) : (
|
||||
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||||
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
|
||||
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("permits.newPermit") : t("permits.editPermit")}</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||||
<label>Holder name</label>
|
||||
<label>{t("permits.holderName")}</label>
|
||||
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||
<label>Contact</label>
|
||||
<label>{t("permits.contact")}</label>
|
||||
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||
<label>Car limit</label>
|
||||
<label>{t("permits.carLimit")}</label>
|
||||
<span>
|
||||
<label style={{ marginRight: "0.5rem" }}>
|
||||
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
|
||||
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("permits.limitCarsInAtOnce")}
|
||||
</label>
|
||||
{form.carBound && (
|
||||
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||||
)}
|
||||
</span>
|
||||
<label>Valid from</label>
|
||||
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
|
||||
<label>Valid to</label>
|
||||
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
|
||||
<label>Bound plates</label>
|
||||
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
|
||||
<label>{t("permits.validFrom")}</label>
|
||||
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
|
||||
<label>{t("permits.validTo")}</label>
|
||||
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
|
||||
<label>{t("permits.boundPlates")}</label>
|
||||
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("permits.commaSeparatedOptional")} />
|
||||
</div>
|
||||
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>{t("permits.credentialsCardQr")}</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<option value="rf">RF card/tag</option>
|
||||
<option value="qr">QR</option>
|
||||
<option value="rf">{t("permits.rfCardTag")}</option>
|
||||
<option value="qr">{t("permits.qr")}</option>
|
||||
</select>
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("permits.credentialValue")} style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>{t("permits.addCredential")}</button>
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
A permit needs at least one credential OR one bound plate.
|
||||
{t("permits.needCredentialOrPlate")}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||
<button type="button" onClick={save}>Save</button>
|
||||
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
|
||||
<button type="button" onClick={save}>{t("permits.save")}</button>
|
||||
<button type="button" onClick={() => setEditing(null)}>{t("permits.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
|
||||
|
||||
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
||||
@@ -10,6 +11,7 @@ import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport
|
||||
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
||||
|
||||
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [startedAt, setStartedAt] = useState<string | null>(null);
|
||||
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
||||
const [currency, setCurrency] = useState<string | null>(null);
|
||||
@@ -68,14 +70,14 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
setMoveMsg(null);
|
||||
const major = Number(moveAmount);
|
||||
if (!Number.isFinite(major) || major <= 0) {
|
||||
setMoveMsg("Enter a positive amount.");
|
||||
setMoveMsg(t("shift.enterPositive"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
|
||||
setMoveAmount("");
|
||||
setMoveReason("");
|
||||
setMoveMsg(`Drawer now ${money(r.balanceMinor, currency)}.`);
|
||||
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setMoveMsg((e as Error).message);
|
||||
@@ -84,27 +86,28 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||
<strong>Shift:</strong>{" "}
|
||||
<strong>{t("shift.label")}</strong>{" "}
|
||||
{startedAt ? (
|
||||
<>
|
||||
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "}
|
||||
<span style={{ color: "#16a34a" }}>{t("shift.open")}</span> {t("shift.since")}{" "}
|
||||
{new Date(startedAt).toLocaleString()}{" "}
|
||||
<button type="button" onClick={end} disabled={busy}>
|
||||
{busy ? "Ending…" : "End shift"}
|
||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: "#777" }}>not started</span>{" "}
|
||||
<span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
|
||||
<button type="button" onClick={start} disabled={busy}>
|
||||
{busy ? "Starting…" : "Start shift"}
|
||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Live drawer balance (what's in the till right now / inherited). */}
|
||||
{drawerMinor != null && (
|
||||
<div style={{ marginTop: "0.5rem", color: "#555" }}>
|
||||
Drawer: <strong>{money(drawerMinor, currency)}</strong>
|
||||
{startedAt && <span style={{ color: "#888" }}> (opening float inherited from the prior shift)</span>}
|
||||
{t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
|
||||
{startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -114,24 +117,24 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
|
||||
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
|
||||
Drawer cash (admin) — load or remove the float
|
||||
{t("shift.drawerCashAdmin")}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input
|
||||
value={moveAmount}
|
||||
onChange={(e) => setMoveAmount(e.target.value)}
|
||||
placeholder="amount"
|
||||
placeholder={t("shift.amount")}
|
||||
inputMode="decimal"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<input
|
||||
value={moveReason}
|
||||
onChange={(e) => setMoveReason(e.target.value)}
|
||||
placeholder="reason (e.g. opening float)"
|
||||
placeholder={t("shift.reasonPlaceholder")}
|
||||
style={{ flex: 1, minWidth: 140 }}
|
||||
/>
|
||||
<button type="button" onClick={() => move(1)}>Load +</button>
|
||||
<button type="button" onClick={() => move(-1)}>Remove −</button>
|
||||
<button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
|
||||
<button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
||||
</div>
|
||||
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
|
||||
</div>
|
||||
@@ -139,20 +142,20 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
|
||||
{report && (
|
||||
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
||||
<div>Payments: {report.paymentCount}</div>
|
||||
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
|
||||
<div style={{ marginTop: "0.4rem", color: "#666" }}>— Drawer —</div>
|
||||
<div>Opening float: {money(report.openingFloatMinor, report.currency)}</div>
|
||||
<div>Cash taken: {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>Cash added: {money(report.cashAddedMinor, report.currency)}</div>
|
||||
<div>Cash removed: {money(report.cashRemovedMinor, report.currency)}</div>
|
||||
<div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
|
||||
<div>{t("shift.payments")} {report.paymentCount}</div>
|
||||
<div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
||||
<div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
|
||||
<div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
|
||||
<div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
|
||||
<div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
Expected drawer: {money(report.expectedDrawerMinor, report.currency)}
|
||||
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
|
||||
</div>
|
||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
|
||||
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
||||
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
||||
|
||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||
@@ -7,17 +8,19 @@ import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type S
|
||||
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
||||
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
||||
|
||||
// The optional text fields, in display order, with labels + placeholders.
|
||||
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; label: string; placeholder?: string; multiline?: boolean }> = [
|
||||
{ key: "parkName", label: "Park name", placeholder: "e.g. Acme Parking" },
|
||||
{ key: "operatorName", label: "Operator (legal name)", placeholder: "operating company" },
|
||||
{ key: "nius", label: "NIUS", placeholder: "e.g. L01234567A" },
|
||||
{ key: "address", label: "Address", multiline: true },
|
||||
{ key: "phone", label: "Phone" },
|
||||
{ key: "email", label: "Email" },
|
||||
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
|
||||
// (resolved at render); only `address` is multiline.
|
||||
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
|
||||
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
|
||||
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
|
||||
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
|
||||
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
|
||||
{ key: "phone", labelKey: "site.fieldPhone" },
|
||||
{ key: "email", labelKey: "site.fieldEmail" },
|
||||
];
|
||||
|
||||
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||
const [capInput, setCapInput] = useState("");
|
||||
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||
@@ -52,7 +55,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
try {
|
||||
await saveSiteConfig(patch);
|
||||
reload();
|
||||
setMsg("Saved.");
|
||||
setMsg(t("site.saved"));
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
@@ -60,25 +63,25 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||
<strong>Occupancy:</strong>{" "}
|
||||
<strong>{t("site.occupancy")}</strong>{" "}
|
||||
{occ == null ? (
|
||||
"…"
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600 }}>{occ.count}</span>
|
||||
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"}
|
||||
{occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`}
|
||||
{occ.capacity != null && (
|
||||
<span style={{ color: "#666" }}> · {occ.free} free</span>
|
||||
<span style={{ color: "#666" }}> · {occ.free} {t("site.free")}</span>
|
||||
)}
|
||||
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "}
|
||||
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>{t("site.full")}</span>}{" "}
|
||||
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
|
||||
<label>
|
||||
Capacity (blank = no limit):{" "}
|
||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
|
||||
{t("site.capacityLabel")}{" "}
|
||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||
<input
|
||||
@@ -86,35 +89,33 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
checked={exitVoucherDefault}
|
||||
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
||||
/>
|
||||
Print exit ticket by default
|
||||
<span style={{ color: "#888", fontSize: "0.8rem" }}>
|
||||
(booth far from exit → customer self-exits with a voucher)
|
||||
</span>
|
||||
{t("site.printExitDefault")}
|
||||
<span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
|
||||
</label>
|
||||
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
|
||||
Park details (optional — shown on tickets/receipts)
|
||||
{t("site.parkDetails")}
|
||||
</div>
|
||||
{META_FIELDS.map(({ key, label, placeholder, multiline }) => (
|
||||
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder={placeholder}
|
||||
placeholder={phKey ? t(phKey) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
placeholder={phKey ? t(phKey) : undefined}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<div>
|
||||
<button type="button" onClick={save}>Save</button>
|
||||
<button type="button" onClick={save}>{t("site.save")}</button>
|
||||
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
fetchTariff,
|
||||
@@ -78,6 +79,7 @@ function toStructure(f: FormState): TariffStructure {
|
||||
}
|
||||
|
||||
export function TariffComposer() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<TariffState | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -112,7 +114,7 @@ export function TariffComposer() {
|
||||
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
||||
const fresh = await fetchTariff();
|
||||
setState(fresh);
|
||||
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." });
|
||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||
} catch (e) {
|
||||
const text =
|
||||
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
||||
@@ -126,44 +128,40 @@ export function TariffComposer() {
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Tariff</h2>
|
||||
<h2>{t("tariff.title")}</h2>
|
||||
{!state?.active ? (
|
||||
<p style={{ color: "#b45309" }}>
|
||||
No rate card published yet — the pay station can't charge until you publish one.
|
||||
</p>
|
||||
<p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
|
||||
) : (
|
||||
<p style={{ color: "#555" }}>
|
||||
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
|
||||
{state.versions.length} version(s) in history. Publishing creates a new version; past
|
||||
sessions keep their original pricing.
|
||||
{t("tariff.activeSince", {
|
||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||
count: state.versions.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
||||
<label>Currency</label>
|
||||
<label>{t("tariff.currency")}</label>
|
||||
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
|
||||
<label>Free entry grace (min)</label>
|
||||
<label>{t("tariff.freeEntryGrace")}</label>
|
||||
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label>Billing increment (min)</label>
|
||||
<label>{t("tariff.billingIncrement")}</label>
|
||||
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label>Daily cap (blank = none)</label>
|
||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
|
||||
<label>Lost-ticket fee</label>
|
||||
<label>{t("tariff.dailyCap")}</label>
|
||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||
<label>{t("tariff.lostTicketFee")}</label>
|
||||
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label>Exit walk-back grace (min)</label>
|
||||
<label>{t("tariff.exitGrace")}</label>
|
||||
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
|
||||
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last
|
||||
block's bound blank for "thereafter". Price is per billing increment.
|
||||
</p>
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.upToMin")}</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -174,7 +172,7 @@ export function TariffComposer() {
|
||||
<input
|
||||
value={b.uptoMin}
|
||||
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
||||
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
|
||||
placeholder={i === form.blocks.length - 1 ? t("tariff.thereafter") : t("tariff.egExample")}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
</td>
|
||||
@@ -183,7 +181,7 @@ export function TariffComposer() {
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
||||
Remove
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -191,12 +189,12 @@ export function TariffComposer() {
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||
+ Add block
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button type="button" onClick={publish} disabled={saving}>
|
||||
{saving ? "Publishing…" : "Publish new version"}
|
||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||
</button>
|
||||
</div>
|
||||
{msg && (
|
||||
|
||||
@@ -45,10 +45,13 @@ export class ApiError extends Error {
|
||||
// --- Auth -----------------------------------------------------------------
|
||||
|
||||
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
||||
export type Lang = "sq" | "en";
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
/** Preferred UI language (loaded from the server on login). */
|
||||
language: Lang;
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): Promise<SessionUser> {
|
||||
@@ -62,6 +65,11 @@ export function logout(): Promise<{ ok: boolean }> {
|
||||
return apiFetch("/api/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Persist the current user's UI language preference (restored on next login). */
|
||||
export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
|
||||
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });
|
||||
}
|
||||
|
||||
/** Returns the current user, or null if not authenticated. */
|
||||
export async function fetchMe(): Promise<SessionUser | null> {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// English (en). Mirrors the key structure of sq.ts (the default/fallback). Any key
|
||||
// missing here falls back to Albanian. See wiki/concepts/i18n.md.
|
||||
|
||||
import type { Catalog } from "./sq.js";
|
||||
|
||||
export const en: Catalog = {
|
||||
common: {
|
||||
loading: "Loading…",
|
||||
logout: "Log out",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
none: "—",
|
||||
},
|
||||
auth: {
|
||||
title: "Parking System",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
signIn: "Sign in",
|
||||
signingIn: "Signing in…",
|
||||
},
|
||||
nav: {
|
||||
booth: "Booth",
|
||||
shift: "Shift",
|
||||
setup: "Setup",
|
||||
tariff: "Tariff",
|
||||
permits: "Permits",
|
||||
site: "Site",
|
||||
},
|
||||
status: {
|
||||
live: "LIVE",
|
||||
connecting: "CONNECTING",
|
||||
offline: "OFFLINE",
|
||||
},
|
||||
booth: {
|
||||
processTicket: "Process ticket",
|
||||
scanPlaceholder: "Scan or type ticket number…",
|
||||
open: "Open",
|
||||
occupancy: "Occupancy",
|
||||
occUnavailable: "occupancy unavailable",
|
||||
inside: "inside",
|
||||
of: "of",
|
||||
uncapped: "uncapped",
|
||||
free: "free",
|
||||
lotFull: "● lot full",
|
||||
liveFeed: "Live feed",
|
||||
events: "events",
|
||||
noEventsYet: "No events yet — entries and exits will stream here.",
|
||||
activeSessions: "Active sessions",
|
||||
insideCount: "inside",
|
||||
noActiveSessions: "No active sessions.",
|
||||
inAt: "in",
|
||||
openPayExit: "Open pay / exit",
|
||||
openBarrier: "Open barrier",
|
||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||
barrierOpened: "barrier opened",
|
||||
openManually: "open manually",
|
||||
badgeExiting: "exiting",
|
||||
badgePaid: "paid",
|
||||
badgeUnpaid: "unpaid",
|
||||
evtEntry: "ENTRY",
|
||||
evtExit: "EXIT",
|
||||
evtPay: "PAY",
|
||||
evtVoid: "VOID",
|
||||
evtOpenCmd: "OPEN→",
|
||||
evtOpenObserved: "OPEN✓",
|
||||
evtShiftOpen: "SHIFT+",
|
||||
evtShiftZ: "SHIFT Z",
|
||||
evtCashMovement: "CASH",
|
||||
evtAnomaly: "ANOMALY",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tariff",
|
||||
noRateCard: "No rate card published yet — the pay station can't charge until you publish one.",
|
||||
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
|
||||
currency: "Currency",
|
||||
freeEntryGrace: "Free entry grace (min)",
|
||||
billingIncrement: "Billing increment (min)",
|
||||
dailyCap: "Daily cap (blank = none)",
|
||||
dailyCapPh: "e.g. 12.00",
|
||||
lostTicketFee: "Lost-ticket fee",
|
||||
exitGrace: "Exit walk-back grace (min)",
|
||||
rateBlocks: "Rate blocks",
|
||||
rateBlocksHint: "Consumed in order as time accrues. \"Up to (min)\" is the block's upper bound; leave the last block's bound blank for \"thereafter\". Price is per billing increment.",
|
||||
upToMin: "Up to (min)",
|
||||
pricePerIncrement: "Price / increment",
|
||||
thereafter: "thereafter",
|
||||
egExample: "e.g. 60",
|
||||
remove: "Remove",
|
||||
addBlock: "+ Add block",
|
||||
publishNewVersion: "Publish new version",
|
||||
publishing: "Publishing…",
|
||||
publishedOk: "New tariff version published — it's now the active rate card.",
|
||||
},
|
||||
permits: {
|
||||
title: "Permits",
|
||||
unnamed: "(unnamed)",
|
||||
unbound: "unbound",
|
||||
car_one: "{{count}} car",
|
||||
car_other: "{{count}} cars",
|
||||
cred: "cred",
|
||||
plates: "{{count}} plate(s)",
|
||||
edit: "Edit",
|
||||
revoke: "Revoke",
|
||||
delete: "Delete",
|
||||
noPermitsYet: "No permits yet.",
|
||||
addPermit: "+ Add permit",
|
||||
newPermit: "New permit",
|
||||
editPermit: "Edit permit",
|
||||
holderName: "Holder name",
|
||||
contact: "Contact",
|
||||
carLimit: "Car limit",
|
||||
limitCarsInAtOnce: "limit cars in at once",
|
||||
validFrom: "Valid from",
|
||||
validTo: "Valid to",
|
||||
isoDateOptional: "ISO date (optional)",
|
||||
boundPlates: "Bound plates",
|
||||
commaSeparatedOptional: "comma-separated (optional)",
|
||||
credentialsCardQr: "Credentials (card / QR)",
|
||||
rfCardTag: "RF card/tag",
|
||||
qr: "QR",
|
||||
credentialValue: "credential value",
|
||||
addCredential: "+ credential",
|
||||
needCredentialOrPlate: "A permit needs at least one credential OR one bound plate.",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
permitSaved: "Permit saved.",
|
||||
confirmRevoke: "Revoke permit for {{name}}? It will be refused at the barrier.",
|
||||
confirmDelete: "Delete permit for {{name}}? (Past events are kept.)",
|
||||
statusActive: "active",
|
||||
statusSuspended: "suspended",
|
||||
statusRevoked: "revoked",
|
||||
},
|
||||
site: {
|
||||
occupancy: "Occupancy:",
|
||||
noCapacitySet: "(no capacity set)",
|
||||
free: "free",
|
||||
full: "FULL",
|
||||
capacityLabel: "Capacity (blank = no limit):",
|
||||
capacityPlaceholder: "e.g. 120",
|
||||
printExitDefault: "Print exit ticket by default",
|
||||
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
||||
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
||||
save: "Save",
|
||||
saved: "Saved.",
|
||||
fieldParkName: "Park name",
|
||||
fieldParkNamePh: "e.g. Acme Parking",
|
||||
fieldOperator: "Operator (legal name)",
|
||||
fieldOperatorPh: "operating company",
|
||||
fieldNius: "NIUS",
|
||||
fieldNiusPh: "e.g. L01234567A",
|
||||
fieldAddress: "Address",
|
||||
fieldPhone: "Phone",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
shift: {
|
||||
label: "Shift:",
|
||||
open: "open",
|
||||
notStarted: "not started",
|
||||
since: "since",
|
||||
startShift: "Start shift",
|
||||
starting: "Starting…",
|
||||
endShift: "End shift",
|
||||
ending: "Ending…",
|
||||
drawer: "Drawer:",
|
||||
openingFloatInherited: "(opening float inherited from the prior shift)",
|
||||
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
|
||||
amount: "amount",
|
||||
reasonPlaceholder: "reason (e.g. opening float)",
|
||||
load: "Load +",
|
||||
remove: "Remove −",
|
||||
enterPositive: "Enter a positive amount.",
|
||||
drawerNow: "Drawer now {{amount}}.",
|
||||
zReport: "Z-REPORT",
|
||||
payments: "Payments:",
|
||||
cash: "Cash:",
|
||||
card: "Card:",
|
||||
drawerSection: "— Drawer —",
|
||||
openingFloat: "Opening float:",
|
||||
cashTaken: "Cash taken:",
|
||||
cashAdded: "Cash added:",
|
||||
cashRemoved: "Cash removed:",
|
||||
expectedDrawer: "Expected drawer:",
|
||||
printedToReceipt: "Printed to booth receipt.",
|
||||
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Ticket",
|
||||
entry: "Entry",
|
||||
now: "Now",
|
||||
duration: "Duration",
|
||||
statusLabel: "Status",
|
||||
paid: "PAID",
|
||||
unpaid: "UNPAID",
|
||||
total: "Total",
|
||||
noTariff: "no tariff",
|
||||
tender: "Tender",
|
||||
cash: "Cash",
|
||||
card: "Card",
|
||||
printExitVoucher: "Print exit ticket",
|
||||
selfExitHint: "(customer self-exits at the exit)",
|
||||
payAndOpen: "Pay + open barrier",
|
||||
payAndVoucher: "Pay + print voucher",
|
||||
openBarrier: "Open barrier",
|
||||
printVoucher: "Print voucher",
|
||||
takingPayment: "taking payment…",
|
||||
printingVoucher: "printing voucher…",
|
||||
opening: "opening…",
|
||||
noSessionFound: "No session found for this ticket.",
|
||||
alreadyClosed: "This session is already closed (exited {{time}}).",
|
||||
lookingUp: "looking up…",
|
||||
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
||||
noSnapshots: "no snapshots",
|
||||
loadingSnapshots: "loading snapshots…",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { sq } from "./sq.js";
|
||||
import { en } from "./en.js";
|
||||
|
||||
// i18next setup for the operator UI. Albanian (sq) is the DEFAULT and the fallback;
|
||||
// English (en) is the second language. The active language is the LOGGED-IN USER's
|
||||
// stored preference (users.language), applied via setLanguage() after auth resolves
|
||||
// — not localStorage, not the browser. Printed tickets are NOT governed by this
|
||||
// (always Albanian, customer-facing). See wiki/concepts/i18n.md.
|
||||
|
||||
export type Lang = "sq" | "en";
|
||||
|
||||
// Single flat namespace; keys are dot-paths (e.g. "booth.processTicket"). Nested
|
||||
// objects in the catalogs are walked by i18next's keySeparator.
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
sq: { translation: sq },
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: "sq",
|
||||
fallbackLng: "sq",
|
||||
interpolation: { escapeValue: false }, // React already escapes
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
/** Apply a language (e.g. after login resolves the user's preference). No-op if
|
||||
* already active. */
|
||||
export function setLanguage(lang: Lang): void {
|
||||
if (i18n.language !== lang) void i18n.changeLanguage(lang);
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,227 @@
|
||||
// Albanian (sq) — the DEFAULT and fallback language. Customer/operator-facing copy.
|
||||
// Keys are dot-namespaced by area (common, nav, booth, …). When adding a string,
|
||||
// add it here first (the fallback), then mirror the key in en.ts.
|
||||
// See wiki/concepts/i18n.md.
|
||||
|
||||
export const sq = {
|
||||
common: {
|
||||
loading: "Duke u ngarkuar…",
|
||||
logout: "Dil",
|
||||
cancel: "Anulo",
|
||||
close: "Mbyll",
|
||||
save: "Ruaj",
|
||||
none: "—",
|
||||
},
|
||||
auth: {
|
||||
title: "Sistemi i Parkimit",
|
||||
username: "Përdoruesi",
|
||||
password: "Fjalëkalimi",
|
||||
signIn: "Hyr",
|
||||
signingIn: "Duke hyrë…",
|
||||
},
|
||||
nav: {
|
||||
booth: "Kabina",
|
||||
shift: "Turni",
|
||||
setup: "Konfigurimi",
|
||||
tariff: "Tarifa",
|
||||
permits: "Lejet",
|
||||
site: "Vendi",
|
||||
},
|
||||
status: {
|
||||
live: "DREJTPËRDREJT",
|
||||
connecting: "DUKE U LIDHUR",
|
||||
offline: "JASHTË LINJE",
|
||||
},
|
||||
booth: {
|
||||
processTicket: "Proceso biletën",
|
||||
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||
open: "Hap",
|
||||
occupancy: "Zënia",
|
||||
occUnavailable: "zënia e padisponueshme",
|
||||
inside: "brenda",
|
||||
of: "nga",
|
||||
uncapped: "pa kufi",
|
||||
free: "lirë",
|
||||
lotFull: "● parkimi plot",
|
||||
liveFeed: "Aktiviteti i drejtpërdrejtë",
|
||||
events: "ngjarje",
|
||||
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||
activeSessions: "Sesionet aktive",
|
||||
insideCount: "brenda",
|
||||
noActiveSessions: "Asnjë sesion aktiv.",
|
||||
inAt: "në",
|
||||
openPayExit: "Hap pagesën / daljen",
|
||||
openBarrier: "Hap barrierën",
|
||||
openBarrierTitle: "Hapje barriere me ndërhyrje njerëzore (e regjistruar)",
|
||||
barrierOpened: "barriera u hap",
|
||||
openManually: "hape me dorë",
|
||||
// session row badges
|
||||
badgeExiting: "duke dalë",
|
||||
badgePaid: "paguar",
|
||||
badgeUnpaid: "papaguar",
|
||||
// event types (live feed labels)
|
||||
evtEntry: "HYRJE",
|
||||
evtExit: "DALJE",
|
||||
evtPay: "PAGESË",
|
||||
evtVoid: "ANULIM",
|
||||
evtOpenCmd: "HAP→",
|
||||
evtOpenObserved: "HAP✓",
|
||||
evtShiftOpen: "TURN+",
|
||||
evtShiftZ: "TURN Z",
|
||||
evtCashMovement: "ARKË",
|
||||
evtAnomaly: "ANOMALI",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tarifa",
|
||||
noRateCard: "Asnjë kartë tarifore e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
|
||||
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
|
||||
currency: "Monedha",
|
||||
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
|
||||
billingIncrement: "Intervali i faturimit (min)",
|
||||
dailyCap: "Kufiri ditor (bosh = pa kufi)",
|
||||
dailyCapPh: "p.sh. 12.00",
|
||||
lostTicketFee: "Tarifa për biletë të humbur",
|
||||
exitGrace: "Periudha e kthimit në dalje (min)",
|
||||
rateBlocks: "Blloqet tarifore",
|
||||
rateBlocksHint: "Konsumohen me radhë me kalimin e kohës. \"Deri në (min)\" është kufiri i sipërm i bllokut; lëre bosh kufirin e bllokut të fundit për \"më pas\". Çmimi është për interval faturimi.",
|
||||
upToMin: "Deri në (min)",
|
||||
pricePerIncrement: "Çmimi / interval",
|
||||
thereafter: "më pas",
|
||||
egExample: "p.sh. 60",
|
||||
remove: "Hiq",
|
||||
addBlock: "+ Shto bllok",
|
||||
publishNewVersion: "Publiko version të ri",
|
||||
publishing: "Duke publikuar…",
|
||||
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
|
||||
},
|
||||
permits: {
|
||||
title: "Lejet",
|
||||
unnamed: "(pa emër)",
|
||||
unbound: "pa kufizim",
|
||||
car_one: "{{count}} makinë",
|
||||
car_other: "{{count}} makina",
|
||||
cred: "kredencial",
|
||||
plates: "{{count}} targë(a)",
|
||||
edit: "Ndrysho",
|
||||
revoke: "Anulo",
|
||||
delete: "Fshij",
|
||||
noPermitsYet: "Asnjë leje ende.",
|
||||
addPermit: "+ Shto leje",
|
||||
newPermit: "Leje e re",
|
||||
editPermit: "Ndrysho lejen",
|
||||
holderName: "Emri i mbajtësit",
|
||||
contact: "Kontakti",
|
||||
carLimit: "Kufiri i makinave",
|
||||
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
|
||||
validFrom: "Vlen nga",
|
||||
validTo: "Vlen deri",
|
||||
isoDateOptional: "Datë ISO (opsionale)",
|
||||
boundPlates: "Targat e lidhura",
|
||||
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
||||
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||
rfCardTag: "Kartë/etiketë RF",
|
||||
qr: "QR",
|
||||
credentialValue: "vlera e kredencialit",
|
||||
addCredential: "+ kredencial",
|
||||
needCredentialOrPlate: "Një leje kërkon të paktën një kredencial OSE një targë të lidhur.",
|
||||
save: "Ruaj",
|
||||
cancel: "Anulo",
|
||||
permitSaved: "Leja u ruajt.",
|
||||
confirmRevoke: "Të anulohet leja për {{name}}? Do të refuzohet te barriera.",
|
||||
confirmDelete: "Të fshihet leja për {{name}}? (Ngjarjet e kaluara ruhen.)",
|
||||
statusActive: "aktive",
|
||||
statusSuspended: "pezulluar",
|
||||
statusRevoked: "anuluar",
|
||||
},
|
||||
site: {
|
||||
occupancy: "Zënia:",
|
||||
noCapacitySet: "(pa kapacitet të caktuar)",
|
||||
free: "lirë",
|
||||
full: "PLOT",
|
||||
capacityLabel: "Kapaciteti (bosh = pa kufi):",
|
||||
capacityPlaceholder: "p.sh. 120",
|
||||
printExitDefault: "Printo biletën e daljes si parazgjedhje",
|
||||
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
|
||||
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
||||
save: "Ruaj",
|
||||
saved: "U ruajt.",
|
||||
fieldParkName: "Emri i parkimit",
|
||||
fieldParkNamePh: "p.sh. Acme Parking",
|
||||
fieldOperator: "Operatori (emri ligjor)",
|
||||
fieldOperatorPh: "kompania operuese",
|
||||
fieldNius: "NIUS",
|
||||
fieldNiusPh: "p.sh. L01234567A",
|
||||
fieldAddress: "Adresa",
|
||||
fieldPhone: "Telefoni",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
shift: {
|
||||
label: "Turni:",
|
||||
open: "hapur",
|
||||
notStarted: "i panisur",
|
||||
since: "që nga",
|
||||
startShift: "Fillo turnin",
|
||||
starting: "Duke filluar…",
|
||||
endShift: "Mbyll turnin",
|
||||
ending: "Duke mbyllur…",
|
||||
drawer: "Arka:",
|
||||
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||
amount: "shuma",
|
||||
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
|
||||
load: "Shto +",
|
||||
remove: "Hiq −",
|
||||
enterPositive: "Shkruaj një shumë pozitive.",
|
||||
drawerNow: "Arka tani {{amount}}.",
|
||||
zReport: "RAPORT Z",
|
||||
payments: "Pagesa:",
|
||||
cash: "Para:",
|
||||
card: "Kartë:",
|
||||
drawerSection: "— Arka —",
|
||||
openingFloat: "Bilanci fillestar:",
|
||||
cashTaken: "Para të marra:",
|
||||
cashAdded: "Para të shtuara:",
|
||||
cashRemoved: "Para të hequra:",
|
||||
expectedDrawer: "Arka e pritshme:",
|
||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Bileta",
|
||||
entry: "Hyrja",
|
||||
now: "Tani",
|
||||
duration: "Kohëzgjatja",
|
||||
statusLabel: "Statusi",
|
||||
paid: "PAGUAR",
|
||||
unpaid: "PAPAGUAR",
|
||||
total: "Totali",
|
||||
noTariff: "pa tarifë",
|
||||
tender: "Mënyra",
|
||||
cash: "Para",
|
||||
card: "Kartë",
|
||||
printExitVoucher: "Printo biletë dalje",
|
||||
selfExitHint: "(klienti del vetë te dalja)",
|
||||
payAndOpen: "Paguaj + hap barrierën",
|
||||
payAndVoucher: "Paguaj + printo biletën",
|
||||
openBarrier: "Hap barrierën",
|
||||
printVoucher: "Printo biletën",
|
||||
takingPayment: "Duke marrë pagesën…",
|
||||
printingVoucher: "Duke printuar biletën…",
|
||||
opening: "Duke hapur…",
|
||||
noSessionFound: "Nuk u gjet asnjë sesion për këtë biletë.",
|
||||
alreadyClosed: "Ky sesion është mbyllur tashmë (doli {{time}}).",
|
||||
lookingUp: "Duke kërkuar…",
|
||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||
// snapshots
|
||||
noSnapshots: "asnjë foto",
|
||||
loadingSnapshots: "duke ngarkuar fotot…",
|
||||
},
|
||||
};
|
||||
|
||||
// The catalog SHAPE (keys + nesting), with string-typed values — so en.ts must
|
||||
// supply every key but may differ in value. (Not `typeof sq` with `as const`, which
|
||||
// would pin en.ts to the Albanian literals.)
|
||||
type Stringify<T> = { [K in keyof T]: T[K] extends object ? Stringify<T[K]> : string };
|
||||
export type Catalog = Stringify<typeof sq>;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import "./lib/i18n/index.js"; // initialize i18next before the app renders
|
||||
import { App } from "./App.js";
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
|
||||
+50
-9
@@ -6,9 +6,11 @@ import {
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router";
|
||||
import type { SessionUser } from "./api.js";
|
||||
import { logout } from "./api.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lang, SessionUser } from "./api.js";
|
||||
import { logout, setLanguagePref } from "./api.js";
|
||||
import { queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
import { BoothScreen } from "./BoothScreen.js";
|
||||
@@ -43,8 +45,46 @@ function NavLink({ to, label }: { to: string; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
|
||||
* and applies it immediately. Updates the router-context user so App re-syncs. */
|
||||
function LanguageToggle({
|
||||
user,
|
||||
setUser,
|
||||
}: {
|
||||
user: SessionUser;
|
||||
setUser: (u: SessionUser | null) => void;
|
||||
}) {
|
||||
async function pick(lang: Lang) {
|
||||
if (lang === user.language) return;
|
||||
setLanguage(lang); // instant UI
|
||||
setUser({ ...user, language: lang });
|
||||
try {
|
||||
await setLanguagePref(lang); // persist
|
||||
} catch {
|
||||
/* non-fatal — the choice still applies this session */
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
||||
{(["sq", "en"] as const).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => pick(l)}
|
||||
className={`rounded-term px-1.5 py-0.5 ${
|
||||
user.language === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
const { user, setUser } = rootRoute.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
// One app-wide WebSocket for the live feed (booth + any live widget).
|
||||
useLiveFeed();
|
||||
const isAdmin = user?.role === "admin";
|
||||
@@ -54,14 +94,15 @@ function RootLayout() {
|
||||
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label="Booth" />
|
||||
<NavLink to="/shift" label="Shift" />
|
||||
{isAdmin && <NavLink to="/setup" label="Setup" />}
|
||||
{isAdmin && <NavLink to="/tariff" label="Tariff" />}
|
||||
{isAdmin && <NavLink to="/permits" label="Permits" />}
|
||||
{isAdmin && <NavLink to="/site" label="Site" />}
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shift" label={t("nav.shift")} />
|
||||
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
|
||||
{isAdmin && <NavLink to="/permits" label={t("nav.permits")} />}
|
||||
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
<span className="text-[11px] text-term-muted">
|
||||
{user?.username} · {user?.role}
|
||||
@@ -74,7 +115,7 @@ function RootLayout() {
|
||||
setUser(null);
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
{t("common.logout")}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
|
||||
|
||||
@@ -7,6 +8,7 @@ import { fetchSnapshots, snapshotImageUrl } from "../api.js";
|
||||
// served with a long immutable cache); clicking one enlarges it. Read-only.
|
||||
|
||||
export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["snapshots", identity],
|
||||
queryFn: () => fetchSnapshots(identity),
|
||||
@@ -16,8 +18,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
|
||||
const shots = data?.snapshots ?? [];
|
||||
|
||||
if (isLoading) return <div className="text-[11px] text-term-muted">loading snapshots…</div>;
|
||||
if (shots.length === 0) return <div className="text-[11px] text-term-muted">no snapshots</div>;
|
||||
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||
if (shots.length === 0) return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLiveStore, type WsStatus } from "../lib/live-store.js";
|
||||
|
||||
// Small live-connection indicator for the booth chrome: a coloured dot + label
|
||||
@@ -8,20 +9,21 @@ const COLOR: Record<WsStatus, string> = {
|
||||
connecting: "bg-term-amber",
|
||||
closed: "bg-term-red",
|
||||
};
|
||||
const LABEL: Record<WsStatus, string> = {
|
||||
open: "LIVE",
|
||||
connecting: "CONNECTING",
|
||||
closed: "OFFLINE",
|
||||
const LABEL_KEY: Record<WsStatus, string> = {
|
||||
open: "status.live",
|
||||
connecting: "status.connecting",
|
||||
closed: "status.offline",
|
||||
};
|
||||
|
||||
export function StatusDot() {
|
||||
const { t } = useTranslation();
|
||||
const status = useLiveStore((s) => s.status);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
|
||||
/>
|
||||
{LABEL[status]}
|
||||
{t(LABEL_KEY[status])}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user