feat(subscription): QR credentials — operator-choose (QR-only now), auto-generate, multi-month, printed card
Builds out subscription credentials on top of the rename.
- Operator chooses the credential type; only QR is live (RFID shown disabled
"soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
globally-unique-checked) — the customer/operator never picks the value.
RF stays operator-entered (the physical card id). Reader output decided =
TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
(GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
create (best-effort — never fails the create; returns {printed,printError})
+ reprint via POST /api/subscriptions/:id/print and a "Print code" button.
Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
deleteSubscription,
|
||||
fetchSiteConfig,
|
||||
fetchSubscriptions,
|
||||
printSubscription,
|
||||
revokeSubscription,
|
||||
updateSubscription,
|
||||
type Subscription,
|
||||
@@ -28,11 +29,17 @@ interface FormState {
|
||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||
maxConcurrent: string;
|
||||
validFrom: string;
|
||||
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
|
||||
validTo: string;
|
||||
credentials: SubscriptionCredential[];
|
||||
platesText: string; // comma/space separated
|
||||
}
|
||||
|
||||
/** Today (UTC date, yyyy-mm-dd) for a sensible default validFrom on new subs. */
|
||||
function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
|
||||
return {
|
||||
holderName: "",
|
||||
@@ -41,9 +48,10 @@ function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormSta
|
||||
currency,
|
||||
carBound: true,
|
||||
maxConcurrent: "1",
|
||||
validFrom: "",
|
||||
validFrom: todayISODate(),
|
||||
months: "1",
|
||||
validTo: "",
|
||||
credentials: [{ kind: "rf", value: "" }],
|
||||
credentials: [{ kind: "qr", value: "" }],
|
||||
platesText: "",
|
||||
};
|
||||
}
|
||||
@@ -56,11 +64,23 @@ function formFrom(s: Subscription): FormState {
|
||||
carBound: s.maxConcurrent != null,
|
||||
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
||||
validFrom: s.validFrom ?? "",
|
||||
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
|
||||
validTo: s.validTo ?? "",
|
||||
credentials: s.credentials.length ? s.credentials : [{ kind: "rf", value: "" }],
|
||||
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
|
||||
platesText: s.plates.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
|
||||
* server's addMonths so the form can preview the coverage end. */
|
||||
function addMonthsDate(date: string, months: number): string | null {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const day = d.getUTCDate();
|
||||
d.setUTCMonth(d.getUTCMonth() + months);
|
||||
if (d.getUTCDate() < day) d.setUTCDate(0);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
const STATUS_KEY: Record<Subscription["status"], string> = {
|
||||
active: "subs.statusActive",
|
||||
suspended: "subs.statusSuspended",
|
||||
@@ -70,6 +90,7 @@ const STATUS_KEY: Record<Subscription["status"], string> = {
|
||||
function toInput(f: FormState): SubscriptionInput {
|
||||
const major = Number(f.priceMajor);
|
||||
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
|
||||
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
|
||||
return {
|
||||
holderName: f.holderName.trim() || null,
|
||||
contact: f.contact.trim() || null,
|
||||
@@ -78,8 +99,14 @@ function toInput(f: FormState): SubscriptionInput {
|
||||
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,
|
||||
// months (with validFrom) drives validTo server-side; else send the explicit end.
|
||||
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
|
||||
validTo: f.validTo.trim() || null,
|
||||
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
|
||||
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
|
||||
// server auto-generates the code. RF (and pre-existing QR) keep their value.
|
||||
credentials: f.credentials
|
||||
.filter((c) => c.kind === "qr" || c.value.trim())
|
||||
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
|
||||
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
@@ -128,8 +155,22 @@ export function SubscriptionManager() {
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
if (editing === "new") await createSubscription(toInput(form));
|
||||
else if (editing) await updateSubscription(editing, toInput(form));
|
||||
if (editing === "new") {
|
||||
const created = await createSubscription(toInput(form));
|
||||
setEditing(null);
|
||||
reload();
|
||||
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
|
||||
// operator can use "Print code" to retry).
|
||||
if (created.printed) {
|
||||
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
|
||||
} else if (created.printError) {
|
||||
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
|
||||
} else {
|
||||
setMsg({ kind: "ok", text: t("subs.saved") });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (editing) await updateSubscription(editing, toInput(form));
|
||||
setEditing(null);
|
||||
reload();
|
||||
setMsg({ kind: "ok", text: t("subs.saved") });
|
||||
@@ -138,6 +179,15 @@ export function SubscriptionManager() {
|
||||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||
}
|
||||
}
|
||||
async function doPrint(s: Subscription) {
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await printSubscription(s.id);
|
||||
setMsg({ kind: "ok", text: t("subs.printedOn", { printer: r.printedBy }) });
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (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 }));
|
||||
@@ -153,6 +203,19 @@ export function SubscriptionManager() {
|
||||
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||||
}
|
||||
|
||||
// Live coverage preview: when months + validFrom are set, show the end date and
|
||||
// (if priced) the N×monthly total the operator should collect.
|
||||
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
|
||||
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
|
||||
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
|
||||
const totalDue =
|
||||
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
|
||||
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
|
||||
: null;
|
||||
const coverageHint = coverageEnd
|
||||
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
|
||||
: null;
|
||||
|
||||
if (!subs) return null;
|
||||
|
||||
return (
|
||||
@@ -169,6 +232,10 @@ export function SubscriptionManager() {
|
||||
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{/* Print code — only when the subscription has a QR credential to encode. */}
|
||||
{s.credentials.some((c) => c.kind === "qr") && (
|
||||
<button type="button" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
|
||||
)}
|
||||
<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>
|
||||
@@ -209,25 +276,51 @@ export function SubscriptionManager() {
|
||||
)}
|
||||
</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")} />
|
||||
<input type="date" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
||||
<label>{t("subs.months")}</label>
|
||||
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input
|
||||
value={form.months}
|
||||
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
placeholder="1"
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
<span style={{ color: "#888" }}>{t("subs.monthsHint")}</span>
|
||||
{/* Live preview of the coverage end + the N×price total. */}
|
||||
{coverageHint && <span style={{ color: "#0a7" }}>{coverageHint}</span>}
|
||||
</span>
|
||||
<label>{t("subs.validToOverride")}</label>
|
||||
<input type="date" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
||||
<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>
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||
{/* Operator chooses the credential type. Only QR is live today; RFID
|
||||
is shown disabled ("soon") so the choice is visible — the backend
|
||||
already accepts both, so re-enabling RFID is just dropping `disabled`. */}
|
||||
<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>
|
||||
<option value="rf" disabled>{t("subs.rfCardTagSoon")}</option>
|
||||
</select>
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.credentialValue")} style={{ flex: 1 }} />
|
||||
{c.kind === "qr" ? (
|
||||
// QR codes are server-generated. Blank → "will be generated"; an
|
||||
// existing code is shown read-only (it can be printed; never typed).
|
||||
c.value.trim() ? (
|
||||
<input value={c.value} readOnly style={{ flex: 1, fontFamily: "ui-monospace, monospace", background: "#f6f6f6" }} />
|
||||
) : (
|
||||
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
{t("subs.needCredentialOrPlate")}
|
||||
</p>
|
||||
|
||||
+31
-2
@@ -298,16 +298,45 @@ export interface Subscription {
|
||||
credentials: SubscriptionCredential[];
|
||||
plates: string[];
|
||||
}
|
||||
export type SubscriptionInput = Omit<Subscription, "id" | "status"> & {
|
||||
/** A credential as SENT to the server: a QR value may be omitted/blank → the server
|
||||
* auto-generates an unguessable code. RF must carry the card id. */
|
||||
export interface SubscriptionCredentialInput {
|
||||
kind: "rf" | "qr";
|
||||
value?: string;
|
||||
}
|
||||
export type SubscriptionInput = {
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
priceMinor: number | null;
|
||||
period: "monthly";
|
||||
currency: string | null;
|
||||
maxConcurrent: number | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
|
||||
months?: number | null;
|
||||
status?: Subscription["status"];
|
||||
credentials: SubscriptionCredentialInput[];
|
||||
plates: string[];
|
||||
};
|
||||
|
||||
/** The create response = the saved subscription + the auto-print outcome. */
|
||||
export type SubscriptionCreated = Subscription & {
|
||||
printed: boolean;
|
||||
printedBy?: string;
|
||||
printError?: string;
|
||||
};
|
||||
|
||||
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||||
return apiFetch("/api/subscriptions");
|
||||
}
|
||||
export function createSubscription(body: SubscriptionInput): Promise<Subscription> {
|
||||
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
||||
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
/** Re-print a subscription's QR card (failed auto-print / lost card). */
|
||||
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
|
||||
}
|
||||
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
|
||||
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
@@ -144,18 +144,30 @@ export const en: Catalog = {
|
||||
limitCarsInAtOnce: "limit cars in at once",
|
||||
validFrom: "Valid from",
|
||||
validTo: "Valid to",
|
||||
months: "Months",
|
||||
monthsHint: "months paid",
|
||||
coverageHint: "until {{end}}",
|
||||
totalDue: "total {{total}}",
|
||||
validToOverride: "Valid to (manual)",
|
||||
isoDateOptional: "ISO date (optional)",
|
||||
boundPlates: "Bound plates",
|
||||
commaSeparatedOptional: "comma-separated (optional)",
|
||||
credentials: "Credentials",
|
||||
credentialsCardQr: "Credentials (card / QR)",
|
||||
rfCardTag: "RF card/tag",
|
||||
rfCardTagSoon: "RF card/tag (soon)",
|
||||
qr: "QR",
|
||||
qrAutoGen: "QR code is auto-generated on save",
|
||||
credentialValue: "credential value",
|
||||
addCredential: "+ credential",
|
||||
needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
saved: "Subscription saved.",
|
||||
savedPrinted: "Subscription saved — QR code printed.",
|
||||
savedPrintFailed: "Subscription saved, but printing failed ({{error}}). Use \"Print code\".",
|
||||
printCode: "Print code",
|
||||
printedOn: "Code printed on {{printer}}.",
|
||||
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
|
||||
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
|
||||
statusActive: "active",
|
||||
|
||||
@@ -146,18 +146,30 @@ export const sq = {
|
||||
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
|
||||
validFrom: "Vlen nga",
|
||||
validTo: "Vlen deri",
|
||||
months: "Muaj",
|
||||
monthsHint: "muaj të paguar",
|
||||
coverageHint: "deri më {{end}}",
|
||||
totalDue: "gjithsej {{total}}",
|
||||
validToOverride: "Vlen deri (manual)",
|
||||
isoDateOptional: "Datë ISO (opsionale)",
|
||||
boundPlates: "Targat e lidhura",
|
||||
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
||||
credentials: "Kredencialet",
|
||||
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||
rfCardTag: "Kartë/etiketë RF",
|
||||
rfCardTagSoon: "Kartë/etiketë RF (së shpejti)",
|
||||
qr: "QR",
|
||||
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
|
||||
credentialValue: "vlera e kredencialit",
|
||||
addCredential: "+ kredencial",
|
||||
needCredentialOrPlate: "Një abonim kërkon të paktën një kredencial OSE një targë të lidhur.",
|
||||
save: "Ruaj",
|
||||
cancel: "Anulo",
|
||||
saved: "Abonimi u ruajt.",
|
||||
savedPrinted: "Abonimi u ruajt — kodi QR u printua.",
|
||||
savedPrintFailed: "Abonimi u ruajt, por printimi dështoi ({{error}}). Përdor \"Printo kodin\".",
|
||||
printCode: "Printo kodin",
|
||||
printedOn: "Kodi u printua te {{printer}}.",
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user