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}>