feat(subscription): rename permit→subscription + monthly pricing

The "permit/lejet" feature is really a subscription. Full rename of the
mutable master data, plus a recurring monthly price.

- DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions,
  permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id.
- Pricing: per-subscription priceMinor + period(monthly) + currency, with a
  site default (site_config.subscription_monthly_price_minor) pre-filling the form.
- Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts
  (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en).
- The signed ledger `permitId` payload is intentionally kept — immutable
  hash-chained history; renaming it would break verification of past events.

Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed
payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber).

Also carries the device-footer UI surface (api DeviceStatus, router mount,
i18n devices) due to shared-file overlap with the preceding footer commit.

Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions
preserved). Live DB migrated. Full monorepo builds clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 13:15:04 +02:00
parent ca8c7f2fa2
commit 5697137c52
32 changed files with 1008 additions and 675 deletions
-191
View File
@@ -1,191 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createPermit,
deletePermit,
fetchPermits,
revokePermit,
updatePermit,
type Permit,
type PermitCredential,
type PermitInput,
} from "./api.js";
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
// credentials (card/QR) and bound plates. A permit is mutable master data; every
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
interface FormState {
holderName: string;
contact: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
validTo: string;
credentials: PermitCredential[];
platesText: string; // comma/space separated
}
function emptyForm(): FormState {
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
}
function formFrom(p: Permit): FormState {
return {
holderName: p.holderName ?? "",
contact: p.contact ?? "",
carBound: p.maxConcurrent != null,
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
validFrom: p.validFrom ?? "",
validTo: p.validTo ?? "",
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
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,
contact: f.contact.trim() || null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
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);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function reload() {
fetchPermits()
.then((r) => setPermits(r.permits))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(reload, []);
function startNew() {
setForm(emptyForm());
setEditing("new");
setMsg(null);
}
function startEdit(p: Permit) {
setForm(formFrom(p));
setEditing(p.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") await createPermit(toInput(form));
else if (editing) await updatePermit(editing, toInput(form));
setEditing(null);
reload();
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(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(t("permits.confirmDelete", { name: p.holderName ?? p.id }))) return;
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<PermitCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
if (!permits) return null;
return (
<section style={{ marginTop: "2rem" }}>
<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 ?? 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 ? 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)}>{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" }}>{t("permits.noPermitsYet")}</li>}
</ul>
{editing == null ? (
<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" ? t("permits.newPermit") : t("permits.editPermit")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>{t("permits.holderName")}</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>{t("permits.contact")}</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<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 }))} /> {t("permits.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<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" }}>{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">{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={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: "" }] }))}>{t("permits.addCredential")}</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
{t("permits.needCredentialOrPlate")}
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>{t("permits.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("permits.cancel")}</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createSubscription,
deleteSubscription,
fetchSiteConfig,
fetchSubscriptions,
revokeSubscription,
updateSubscription,
type Subscription,
type SubscriptionCredential,
type SubscriptionInput,
} from "./api.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
// subscription is mutable master data; every USE of it is a signed ledger event
// elsewhere. See wiki/entities/subscription.md.
const DEFAULT_CURRENCY = "ALL";
interface FormState {
holderName: string;
contact: string;
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
currency: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
validTo: string;
credentials: SubscriptionCredential[];
platesText: string; // comma/space separated
}
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
return {
holderName: "",
contact: "",
priceMajor: defaultPriceMajor,
currency,
carBound: true,
maxConcurrent: "1",
validFrom: "",
validTo: "",
credentials: [{ kind: "rf", value: "" }],
platesText: "",
};
}
function formFrom(s: Subscription): FormState {
return {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
currency: s.currency ?? DEFAULT_CURRENCY,
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
validFrom: s.validFrom ?? "",
validTo: s.validTo ?? "",
credentials: s.credentials.length ? s.credentials : [{ kind: "rf", value: "" }],
platesText: s.plates.join(", "),
};
}
const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive",
suspended: "subs.statusSuspended",
revoked: "subs.statusRevoked",
};
function toInput(f: FormState): SubscriptionInput {
const major = Number(f.priceMajor);
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
priceMinor: priceSet ? Math.round(major * 100) : null,
period: "monthly",
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
function priceLabel(s: Subscription, t: (k: string) => string): string {
if (s.priceMinor == null) return t("subs.noPrice");
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
}
export function SubscriptionManager() {
const { t } = useTranslation();
const [subs, setSubs] = useState<Subscription[] | null>(null);
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(() => emptyForm());
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function reload() {
fetchSubscriptions()
.then((r) => setSubs(r.subscriptions))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(() => {
reload();
// Pull the site default monthly price to pre-fill new subscriptions.
fetchSiteConfig()
.then((c) => {
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
})
.catch(() => {
/* non-fatal — the form just won't pre-fill */
});
}, []);
function startNew() {
setForm(emptyForm(defaultPriceMajor));
setEditing("new");
setMsg(null);
}
function startEdit(s: Subscription) {
setForm(formFrom(s));
setEditing(s.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") await createSubscription(toInput(form));
else if (editing) await updateSubscription(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("subs.saved") });
} 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(s: Subscription) {
if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return;
await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(s: Subscription) {
if (!confirm(t("subs.confirmDelete", { name: s.holderName ?? s.id }))) return;
await deleteSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<SubscriptionCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
if (!subs) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>{t("subs.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{subs.map((s) => (
<li key={s.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{s.holderName ?? t("subs.unnamed")}</strong>
<span style={{ color: s.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[s.status])}</span>
<span style={{ color: "#0a7", fontVariantNumeric: "tabular-nums" }}>{priceLabel(s, t)}</span>
<span style={{ color: "#666" }}>
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
</li>
))}
{subs.length === 0 && <li style={{ color: "#777" }}>{t("subs.noneYet")}</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>{t("subs.add")}</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("subs.new") : t("subs.editTitle")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>{t("subs.holderName")}</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>{t("subs.contact")}</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>{t("subs.monthlyPrice")}</label>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<input
value={form.priceMajor}
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
inputMode="decimal"
placeholder={t("subs.pricePlaceholder")}
style={{ width: 110 }}
/>
<input value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} />
<span style={{ color: "#888" }}>/ {t("subs.perMonth")}</span>
</span>
<label>{t("subs.carLimit")}</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>{t("subs.validFrom")}</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("subs.isoDateOptional")} />
<label>{t("subs.validTo")}</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("subs.isoDateOptional")} />
<label>{t("subs.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.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">{t("subs.rfCardTag")}</option>
<option value="qr">{t("subs.qr")}</option>
</select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.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: "" }] }))}>{t("subs.addCredential")}</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
{t("subs.needCredentialOrPlate")}
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>{t("subs.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+44 -16
View File
@@ -277,41 +277,45 @@ export function publishTariffVersion(body: {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
}
// --- Permits --------------------------------------------------------------
// --- Subscriptions --------------------------------------------------------
export interface PermitCredential {
export interface SubscriptionCredential {
kind: "rf" | "qr";
value: string;
}
export interface Permit {
export interface Subscription {
id: string;
holderName: string | null;
contact: string | null;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
status: "active" | "suspended" | "revoked";
credentials: PermitCredential[];
credentials: SubscriptionCredential[];
plates: string[];
}
export type PermitInput = Omit<Permit, "id" | "status"> & {
status?: Permit["status"];
export type SubscriptionInput = Omit<Subscription, "id" | "status"> & {
status?: Subscription["status"];
};
export function fetchPermits(): Promise<{ permits: Permit[] }> {
return apiFetch("/api/permits");
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
return apiFetch("/api/subscriptions");
}
export function createPermit(body: PermitInput): Promise<Permit> {
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
export function createSubscription(body: SubscriptionInput): Promise<Subscription> {
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
}
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function revokePermit(id: string): Promise<Permit> {
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
export function revokeSubscription(id: string): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
}
export function deletePermit(id: string): Promise<void> {
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
export function deleteSubscription(id: string): Promise<void> {
return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" });
}
// --- Shifts ---------------------------------------------------------------
@@ -378,6 +382,8 @@ export interface SiteConfig {
capacity: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault: boolean;
/** Site default monthly subscription price (minor units); pre-fills the form. */
subscriptionMonthlyPriceMinor: number | null;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
@@ -391,6 +397,28 @@ export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy");
}
// --- Device status (the booth footer) -------------------------------------
/** Live status of one configured device — mirrors the server's DeviceStatusEvent.
* Every enabled device is polled (printers via rich readStatus, the rest via
* healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST
* snapshot below is the initial load / fallback. */
export interface DeviceStatus {
deviceId: string;
driverId: string;
category: "access" | "reader" | "camera" | "printer";
/** Role/direction token for the footer label (NOT the vendor) — the client
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
state: "ready" | "degraded" | "offline";
detail?: string;
checkedAt: string;
}
export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
return apiFetch("/api/devices/status");
}
// --- Ledger events (the signed audit trail; read-only) --------------------
/** A persisted ledger row. Re-exported from shared so UI code has one source of
+42 -11
View File
@@ -24,7 +24,7 @@ export const en: Catalog = {
shift: "Shift",
setup: "Setup",
tariff: "Tariff",
permits: "Permits",
subscriptions: "Subscriptions",
site: "Site",
},
status: {
@@ -32,6 +32,33 @@ export const en: Catalog = {
connecting: "CONNECTING",
offline: "OFFLINE",
},
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Barrier",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
exit: "exit",
both: "entry/exit",
mixed: "mixed",
lane: "lane",
booth: "booth",
},
state: {
ready: "ready",
degraded: "degraded",
offline: "offline",
},
allOk: "all ready",
issuesCount: "{{count}} with issues",
issuesTitle: "Device issues",
clickForIssues: "Click for details",
checkedAt: "checked {{time}}",
},
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
@@ -92,21 +119,25 @@ export const en: Catalog = {
publishing: "Publishing…",
publishedOk: "New tariff version published — it's now the active rate card.",
},
permits: {
title: "Permits",
subs: {
title: "Subscriptions",
unnamed: "(unnamed)",
unbound: "unbound",
car_one: "{{count}} car",
car_other: "{{count}} cars",
cred: "cred",
plates: "{{count}} plate(s)",
noPrice: "no price",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
edit: "Edit",
revoke: "Revoke",
delete: "Delete",
noPermitsYet: "No permits yet.",
addPermit: "+ Add permit",
newPermit: "New permit",
editPermit: "Edit permit",
noneYet: "No subscriptions yet.",
add: "+ Add subscription",
new: "New subscription",
editTitle: "Edit subscription",
holderName: "Holder name",
contact: "Contact",
carLimit: "Car limit",
@@ -121,12 +152,12 @@ export const en: Catalog = {
qr: "QR",
credentialValue: "credential value",
addCredential: "+ credential",
needCredentialOrPlate: "A permit needs at least one credential OR one bound plate.",
needCredentialOrPlate: "A subscription 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.)",
saved: "Subscription saved.",
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
statusActive: "active",
statusSuspended: "suspended",
statusRevoked: "revoked",
+46 -15
View File
@@ -24,7 +24,7 @@ export const sq = {
shift: "Turni",
setup: "Konfigurimi",
tariff: "Tarifa",
permits: "Lejet",
subscriptions: "Abonimet",
site: "Vendi",
},
status: {
@@ -32,16 +32,43 @@ export const sq = {
connecting: "DUKE U LIDHUR",
offline: "JASHTË LINJE",
},
devices: {
footerTitle: "Pajisjet",
none: "Asnjë pajisje e konfiguruar.",
catAccess: "Barriera",
catReader: "Lexuesi",
catCamera: "Kamera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
role: {
entry: "hyrje",
exit: "dalje",
both: "hyrje/dalje",
mixed: "i përzier",
lane: "korsia",
booth: "kabina",
},
state: {
ready: "gati",
degraded: "i dëmtuar",
offline: "jashtë linje",
},
allOk: "të gjitha gati",
issuesCount: "{{count}} me probleme",
issuesTitle: "Problemet e pajisjeve",
clickForIssues: "Kliko për detajet",
checkedAt: "kontrolluar {{time}}",
},
booth: {
processTicket: "Proceso biletën",
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
open: "Hap",
occupancy: "Zënia",
occupancy: "Prania",
occUnavailable: "zënia e padisponueshme",
inside: "brenda",
of: "nga",
uncapped: "pa kufi",
free: "lirë",
free: "Vende të lira",
lotFull: "● parkimi plot",
liveFeed: "Aktiviteti live",
events: "ngjarje",
@@ -94,21 +121,25 @@ export const sq = {
publishing: "Duke publikuar…",
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
},
permits: {
title: "Lejet",
subs: {
title: "Abonimet",
unnamed: "(pa emër)",
unbound: "pa kufizim",
car_one: "{{count}} makinë",
car_other: "{{count}} makina",
cred: "kredencial",
plates: "{{count}} targë(a)",
noPrice: "pa çmim",
perMonth: "muaj",
monthlyPrice: "Çmimi mujor",
pricePlaceholder: "p.sh. 10000",
edit: "Ndrysho",
revoke: "Anulo",
delete: "Fshij",
noPermitsYet: "Asnjë leje ende.",
addPermit: "+ Shto leje",
newPermit: "Leje e re",
editPermit: "Ndrysho lejen",
noneYet: "Asnjë abonim ende.",
add: "+ Shto abonim",
new: "Abonim i ri",
editTitle: "Ndrysho abonimin",
holderName: "Emri i mbajtësit",
contact: "Kontakti",
carLimit: "Kufiri i makinave",
@@ -123,18 +154,18 @@ export const sq = {
qr: "QR",
credentialValue: "vlera e kredencialit",
addCredential: "+ kredencial",
needCredentialOrPlate: "Një leje kërkon të paktën një kredencial OSE një targë të lidhur.",
needCredentialOrPlate: "Një abonim 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",
saved: "Abonimi u ruajt.",
confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.",
confirmDelete: "Të fshihet abonimi për {{name}}? (Ngjarjet e kaluara ruhen.)",
statusActive: "aktiv",
statusSuspended: "pezulluar",
statusRevoked: "anuluar",
},
site: {
occupancy: "Zënia:",
occupancy: "Prania:",
noCapacitySet: "(pa kapacitet të caktuar)",
free: "lirë",
full: "PLOT",
+9 -6
View File
@@ -15,11 +15,12 @@ import { qk, queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
import { PermitManager } from "./PermitManager.js";
import { SubscriptionManager } from "./SubscriptionManager.js";
import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js";
@@ -164,7 +165,7 @@ function RootLayout() {
<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="/subscriptions" label={t("nav.subscriptions")} />}
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
@@ -189,6 +190,8 @@ function RootLayout() {
<main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet />
</main>
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
{user && <DeviceFooter />}
</div>
);
}
@@ -233,11 +236,11 @@ const tariffRoute = createRoute({
beforeLoad: ({ context }) => adminOnly(context),
component: () => <TariffComposer />,
});
const permitsRoute = createRoute({
const subscriptionsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/permits",
path: "/subscriptions",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <PermitManager />,
component: () => <SubscriptionManager />,
});
const siteRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -252,7 +255,7 @@ const routeTree = rootRoute.addChildren([
shiftRoute,
setupRoute,
tariffRoute,
permitsRoute,
subscriptionsRoute,
siteRoute,
]);