ticket: site metadata header + scannable Albanian ticket; widen barcode

- site_config gains optional park identity (park_name, operator_name, nius,
  address, phone, email); additive Drizzle migration 0001. GET/PUT
  /api/site-config read/write the full config (PUT partial patch, admin only);
  SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
  all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
  and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
  short-range "Simple" QR/barcode reader decodes reliably (was barely reading
  at module width 2 on the 80mm head).

See wiki/concepts/site-metadata.md and ticket-encoding.md.
This commit is contained in:
2026-06-17 12:17:21 +02:00
parent 1efa77bf56
commit 727c62da90
20 changed files with 1596 additions and 155 deletions
+86 -19
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import {
assignDevice,
editDevice,
discoverDevices,
fetchBackendIps,
fetchCatalog,
@@ -127,8 +128,12 @@ function CategorySection({
onChanged: () => Promise<void> | void;
}) {
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0;
const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined;
// Show the add form for an empty category or an explicit "+ Add", but not while
// editing an existing row (that row renders its own inline form).
const showForm = !editing && (adding || assignments.length === 0);
// Binding categories need a controller to point at first.
const isBound = category !== "access";
@@ -162,15 +167,43 @@ function CategorySection({
{assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
{assignments.map((a) => (
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
))}
{assignments.map((a) =>
editingId === a.id ? (
<li key={a.id} style={{ listStyle: "none", padding: 0 }}>
<DeviceForm
category={category}
entries={entries}
discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
controllers={controllers}
editing={a}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
/>
</li>
) : (
<AssignmentRow
key={a.id}
assignment={a}
controllers={controllers}
onChanged={onChanged}
onEdit={() => {
setAdding(false);
setEditingId(a.id);
}}
/>
),
)}
</ul>
)}
{blockedNoController ? (
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
) : showForm ? (
) : editing ? null : showForm ? (
<DeviceForm
category={category}
entries={entries}
@@ -197,10 +230,12 @@ function AssignmentRow({
assignment,
controllers,
onChanged,
onEdit,
}: {
assignment: Assignment;
controllers: Assignment[];
onChanged: () => Promise<void> | void;
onEdit: () => void;
}) {
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -237,6 +272,9 @@ function AssignmentRow({
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
<span style={{ flex: 1 }} />
{error && <span style={{ color: "crimson" }}>{error}</span>}
<button type="button" onClick={onEdit} disabled={removing}>
Edit
</button>
<button type="button" onClick={remove} disabled={removing}>
{removing ? "Removing…" : "Remove"}
</button>
@@ -280,6 +318,7 @@ function DeviceForm({
discoverableIds,
pushCapableIds,
controllers,
editing,
onSaved,
onCancel,
}: {
@@ -288,21 +327,42 @@ function DeviceForm({
discoverableIds: string[];
pushCapableIds: string[];
controllers: Assignment[];
/** When set, the form edits this assignment in place (driver locked, config
* pre-filled) instead of adding a new device. */
editing?: Assignment;
onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void;
}) {
const [selectedId, setSelectedId] = useState<string>("");
// On edit the driver is fixed (you can't change what KIND of device a slot is —
// that's a remove + re-add); pre-select it and lock the picker.
const editCfg = editing?.config as Record<string, unknown> | undefined;
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id);
const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access";
const [config, setConfig] = useState<Record<string, string | number>>({});
// Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.)
const [config, setConfig] = useState<Record<string, string | number>>(() => {
if (!editCfg) return {};
const out: Record<string, string | number> = {};
for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number") out[k] = v;
}
return out;
});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]);
const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
);
// Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>("");
const [boundRelay, setBoundRelay] = useState<number | "">("");
const [controllerId, setControllerId] = useState<string>(
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
);
const [boundRelay, setBoundRelay] = useState<number | "">(
typeof editCfg?.relay === "number" ? editCfg.relay : "",
);
const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false);
@@ -420,12 +480,17 @@ function DeviceForm({
setSaving(true);
setSaveError(null);
try {
const result = await assignDevice({
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
const result = editing
? await editDevice(editing.id, {
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
})
: await assignDevice({
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
await onSaved(result.warnings ?? []);
} catch (e) {
setSaveError((e as Error).message);
@@ -439,7 +504,9 @@ function DeviceForm({
{entries.length === 0 ? (
<em>No drivers registered.</em>
) : (
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}>
// Driver is locked when editing — changing the kind of device is a
// remove + re-add, not an in-place edit.
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
<option value="" disabled>
Choose a device…
</option>
@@ -538,7 +605,7 @@ function DeviceForm({
{testing ? "Testing…" : "Test connection"}
</button>
<button type="button" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save & configure"}
{saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
</button>
{onCancel && (
<button type="button" onClick={onCancel} disabled={saving}>
+56 -13
View File
@@ -1,14 +1,26 @@
import { useEffect, useState } from "react";
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse
// transient entry at capacity) is enforced server-side in the entry flow.
// See wiki/concepts/capacity-occupancy.md.
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
// 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" },
];
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({});
const [msg, setMsg] = useState<string | null>(null);
function reload() {
@@ -17,18 +29,25 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
useEffect(() => {
reload();
fetchSiteConfig()
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity)))
.then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity));
const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m);
})
.catch(() => {});
}, []);
async function save() {
setMsg(null);
const raw = capInput.trim();
const capacity = raw === "" ? null : Math.round(Number(raw));
const patch: Partial<SiteConfig> = { capacity: raw === "" ? null : Math.round(Number(raw)) };
// Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
try {
await setCapacity(capacity);
await saveSiteConfig(patch);
reload();
setMsg("Capacity saved.");
setMsg("Saved.");
} catch (e) {
setMsg((e as Error).message);
}
@@ -51,13 +70,37 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
</>
)}
{canEdit && (
<div style={{ marginTop: "0.6rem" }}>
<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" />
</label>{" "}
<button type="button" onClick={save}>Save</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</label>
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
Park details (optional — shown on tickets/receipts)
</div>
{META_FIELDS.map(({ key, label, placeholder, multiline }) => (
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
{label}
{multiline ? (
<textarea
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
rows={2}
placeholder={placeholder}
/>
) : (
<input
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
placeholder={placeholder}
/>
)}
</label>
))}
<div>
<button type="button" onClick={save}>Save</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</div>
</div>
)}
</section>
+28 -3
View File
@@ -186,6 +186,15 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
}
/** Re-configure an existing device in place, keeping its id (and so its push
* URL). Category/driver are fixed at create time, so only config changes. */
export function editDevice(
id: string,
body: Omit<AssignBody, "category" | "driverId">,
): Promise<AssignResult> {
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
}
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment {
id: string;
@@ -333,12 +342,28 @@ export interface Occupancy {
full: boolean;
}
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
export interface SiteConfig {
capacity: number | null;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
nius: string | null;
address: string | null;
phone: string | null;
email: string | null;
}
export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy");
}
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
export function fetchSiteConfig(): Promise<SiteConfig> {
return apiFetch("/api/site-config");
}
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
/** PUT a partial config — only the fields supplied are changed. */
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
}
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity });
}