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:
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user