diff --git a/apps/server/src/booth-print.ts b/apps/server/src/booth-print.ts index 327335c..7c56b31 100644 --- a/apps/server/src/booth-print.ts +++ b/apps/server/src/booth-print.ts @@ -1,6 +1,5 @@ import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db"; import { - formatStampSq, printWithFailover, registry, type PrinterDevice, @@ -166,26 +165,19 @@ export async function printSubscriptionCard( */ export async function printWindowChargeNotice( db: Db, - notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number; edge: "entry" | "exit" }, + notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" }, logger: FastifyBaseLogger, ): Promise { const printers = loadPrinters(db); - const hhmm = (m?: number) => - m == null ? "" : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; - const lines = [ - `Abonent: ${notice.holderName || "-"}`, - `${notice.edge === "entry" ? "Hyrje" : "Dalje"}: ${formatStampSq(notice.at)}`, - notice.edge === "entry" - ? `Ka hyrë jashtë orarit${notice.windowOpensMin != null ? ` (orari hap ${hhmm(notice.windowOpensMin)})` : ""}` - : "Ka dalë jashtë orarit", - "", - "⚠ Detyrim do të llogaritet në dalje", - " (paguhet në kabinë para se të dilni)", - "", - `Nr: ${notice.occurrenceId}`, - ]; const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) => - d.printReport({ title: "PARKIM — JASHTË ORARIT", lines }), + d.printWindowChargeNotice({ + occurrenceId: notice.occurrenceId, + holderName: notice.holderName ?? null, + at: notice.at, + edge: notice.edge, + windowOpensMin: notice.windowOpensMin ?? null, + header: ticketHeader(db), + }), ); logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`); return printedBy; diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 670ac76..0c3d1de 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -45,6 +45,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose const [result, setResult] = useState(null); const [openingShift, setOpeningShift] = useState(false); const [reprinting, setReprinting] = useState(false); + // For a PREPAID subscriber with nothing owed, the audited manual barrier open + // (assist a faulty reader / lost card) is no longer the default action — the + // operator reveals it explicitly so the modal isn't an always-on "open" button. + const [assistRevealed, setAssistRevealed] = useState(false); + // For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay + // first, then the modal reveals "Open barrier". This flips true once paid. + const [windowPaid, setWindowPaid] = useState(false); const s: SessionLookup | undefined = session.data; // Checkbox default comes from config the first time it loads; operator can toggle. @@ -87,6 +94,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose } } + // Subscriber out-of-window charge: take the payment, but DON'T exit yet. The + // barrier open is the operator's explicit second step (so the flow reads: + // pay → then Open barrier), mirroring the two-step the operator asked for. + async function handlePaySubscriptionWindow() { + if (!s) return; + setError(null); + setPhase("paying"); + try { + await paySession(identity, tender); + setWindowPaid(true); + setPhase("review"); + void qc.invalidateQueries({ queryKey: ["session", identity] }); + void qc.invalidateQueries({ queryKey: qk.events }); + } catch (e) { + setError((e as Error).message); + setPhase("error"); + } + } + async function handleOpenShift() { setOpeningShift(true); setError(null); @@ -280,17 +306,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose - {/* For a subscription with a window charge, explain why it's payable. For a - plain prepaid subscription, explain the assist-open is the only action. */} - {subWindowDue ? ( + {/* Subscription guidance: an unpaid window charge explains the pay-first + gate; once paid, prompt the operator to open the barrier; a prepaid + subscriber sees the assist explanation only after revealing it. */} + {subWindowDue && !windowPaid ? (
{t("pay.windowChargeHint")}
- ) : isSubscription && ( + ) : isSubscription && windowPaid ? ( +
+ {t("pay.windowPaidHint")} +
+ ) : isSubscription && assistRevealed ? (
{t("pay.subAssistHint")}
- )} + ) : null} {/* For an overstay, explain why a top-up is required (no free exit). */} {isOverstay && ( @@ -302,37 +333,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose {/* Snapshots */} - {phase !== "done" && !isSubscription && ( - <> - {/* Tender */} - {canPay && ( -
- {t("pay.tender")} - {(["cash", "card"] as const).map((tn) => ( - - ))} -
- )} + {/* Tender — shown for any payable case (transient, overstay, OR a + subscriber window charge that's still unpaid). */} + {phase !== "done" && canPay && !(subWindowDue && windowPaid) && ( +
+ {t("pay.tender")} + {(["cash", "card"] as const).map((tn) => ( + + ))} +
+ )} - {/* Voucher checkbox (default from site config) */} - - + {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */} + {phase !== "done" && !isSubscription && ( + )} {error &&
{error}
} @@ -374,16 +404,41 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose {t("common.cancel")} {isSubscription ? ( - // Prepaid — the only action is the audited barrier open (assist - // a faulty exit reader / missing card). Gated on an open shift. - + subWindowDue && !windowPaid ? ( + // Step 1 — a window charge is owed: take payment first. The + // barrier open is the explicit next step (revealed once paid). + + ) : windowPaid || assistRevealed ? ( + // The audited barrier open. Shown only AFTER a window charge is + // settled, or after the operator explicitly reveals the assist — + // never as the default action for a prepaid subscriber. + + ) : ( + // Prepaid, nothing owed: no default open. A small reveal exposes + // the audited manual open for a faulty reader / lost card. + + ) ) : (