feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling
Rounds out subscriptions across enrollment, the barrier flow, and the booth.
- RFID credentials enabled with a "Read card" enrollment flow: the operator
arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
reader's next read is captured into the form and NOT dispatched to the access
flow — the OTHER reader keeps serving live entry/exit. Routes:
/api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
per-occurrence id (SUBSESS-<short>), not the credential value, with
permitId in the payload. Direction is decided by the barrier the reader sits
at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
pay/exit modal shows a subscription mode (snapshots + a single audited
Open-barrier action) to assist a faulty exit reader / missing card;
reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
"abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.
Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -17,6 +17,7 @@ import { Panel } from "./ui/Panel.js";
|
||||
// See wiki/concepts/booth-exit-flow.md.
|
||||
|
||||
function statusBadge(s: ActiveSession): { key: string; cls: string } {
|
||||
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
||||
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
||||
@@ -90,7 +91,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||
title={t("booth.openPayExit")}
|
||||
>
|
||||
<span className="text-term-text">{s.identity}</span>
|
||||
<span className="text-term-text">
|
||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||
</span>
|
||||
<span className="text-term-muted">
|
||||
{t("booth.inAt")} {formatTime(s.enteredAt)}
|
||||
</span>
|
||||
@@ -98,8 +101,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
||||
</button>
|
||||
|
||||
{/* Open barrier — PAID sessions only (no payment, no button). */}
|
||||
{s.paidAt ? (
|
||||
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
|
||||
unpaid transient has no button (no-unpaid-bypass). */}
|
||||
{s.paidAt || s.subscription ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
openShift,
|
||||
paySession,
|
||||
printVoucher,
|
||||
reopenBarrier,
|
||||
type SessionLookup,
|
||||
} from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
@@ -48,7 +49,26 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
||||
|
||||
const alreadyPaid = s?.paidAt != null;
|
||||
const canPay = shiftReady && s?.found && s.open && !alreadyPaid;
|
||||
const isSubscription = s?.subscription === true;
|
||||
// A subscription is prepaid: never charged. The only booth action is an audited
|
||||
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
|
||||
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
|
||||
|
||||
async function handleOpenBarrier() {
|
||||
if (!s) return;
|
||||
setError(null);
|
||||
setPhase("finishing");
|
||||
try {
|
||||
const r = await reopenBarrier(identity);
|
||||
setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }));
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
setPhase("done");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setPhase("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenShift() {
|
||||
setOpeningShift(true);
|
||||
@@ -106,7 +126,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("pay.ticket")} {identity}
|
||||
{isSubscription
|
||||
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
||||
: `${t("pay.ticket")} ${identity}`}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
|
||||
✕
|
||||
@@ -173,27 +195,38 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.statusLabel")}
|
||||
value={alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
||||
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
||||
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
{/* Total — a subscription is prepaid (no amount); show a badge. */}
|
||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.total")}</span>
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{isSubscription ? t("pay.plan") : t("pay.total")}
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
{s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
{isSubscription
|
||||
? t("pay.prepaid")
|
||||
: s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* For a subscription, explain the only available action. */}
|
||||
{isSubscription && (
|
||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
||||
{t("pay.subAssistHint")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshots */}
|
||||
<SnapshotStrip identity={identity} />
|
||||
|
||||
{phase !== "done" && (
|
||||
{phase !== "done" && !isSubscription && (
|
||||
<>
|
||||
{/* Tender */}
|
||||
{canPay && (
|
||||
@@ -253,26 +286,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
{isSubscription ? (
|
||||
// Prepaid — the only action is the audited barrier open (assist
|
||||
// a faulty exit reader / missing card). Gated on an open shift.
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenBarrier}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="rounded-term border border-term-cyan bg-term-cyan/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-cyan disabled:opacity-50"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
? voucher
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
armCapture,
|
||||
cancelCapture,
|
||||
createSubscription,
|
||||
deleteSubscription,
|
||||
fetchReaders,
|
||||
fetchSiteConfig,
|
||||
fetchSubscriptions,
|
||||
pollCapture,
|
||||
printSubscription,
|
||||
revokeSubscription,
|
||||
updateSubscription,
|
||||
type ReaderInfo,
|
||||
type Subscription,
|
||||
type SubscriptionCredential,
|
||||
type SubscriptionInput,
|
||||
@@ -123,6 +128,11 @@ export function SubscriptionManager() {
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [form, setForm] = useState<FormState>(() => emptyForm());
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
// Credential capture ("Read card"): which credential index is being captured, the
|
||||
// reader picker list, and a live status line. null = no capture in progress.
|
||||
const [capture, setCapture] = useState<{ credIndex: number; phase: "pick" | "waiting"; status?: string } | null>(null);
|
||||
const [readers, setReaders] = useState<ReaderInfo[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
function reload() {
|
||||
fetchSubscriptions()
|
||||
@@ -203,6 +213,62 @@ export function SubscriptionManager() {
|
||||
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||||
}
|
||||
|
||||
function clearPoll() {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
// Stop a capture in progress (cancel on the server + clear local state).
|
||||
function stopCapture() {
|
||||
clearPoll();
|
||||
void cancelCapture().catch(() => {});
|
||||
setCapture(null);
|
||||
}
|
||||
// "Read card" on credential i → load readers + show the picker.
|
||||
async function startCapture(i: number) {
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await fetchReaders();
|
||||
setReaders(r.readers);
|
||||
setCapture({ credIndex: i, phase: "pick" });
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
// Operator picked a reader → arm it and poll until captured / expired.
|
||||
async function pickReader(deviceId: string) {
|
||||
const cap = capture;
|
||||
if (!cap) return;
|
||||
try {
|
||||
await armCapture(deviceId);
|
||||
setCapture({ ...cap, phase: "waiting", status: t("subs.captureWaiting") });
|
||||
clearPoll();
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const st = await pollCapture();
|
||||
if (st.status === "captured") {
|
||||
clearPoll();
|
||||
setCred(cap.credIndex, { value: st.value });
|
||||
void cancelCapture().catch(() => {}); // clear the server-side result
|
||||
setCapture(null);
|
||||
setMsg({ kind: "ok", text: t("subs.captured", { value: st.value }) });
|
||||
} else if (st.status === "expired" || st.status === "idle") {
|
||||
clearPoll();
|
||||
setCapture({ ...cap, phase: "waiting", status: t("subs.captureTimeout") });
|
||||
}
|
||||
} catch {
|
||||
/* transient poll error — keep polling */
|
||||
}
|
||||
}, 700);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
setCapture(null);
|
||||
}
|
||||
}
|
||||
// Stop polling if the form closes or the component unmounts.
|
||||
useEffect(() => clearPoll, []);
|
||||
|
||||
// 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));
|
||||
@@ -299,12 +365,11 @@ export function SubscriptionManager() {
|
||||
<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`. */}
|
||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||
(read off a card via "Read card"). */}
|
||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<option value="qr">{t("subs.qr")}</option>
|
||||
<option value="rf" disabled>{t("subs.rfCardTagSoon")}</option>
|
||||
<option value="rf">{t("subs.rfCardTag")}</option>
|
||||
</select>
|
||||
{c.kind === "qr" ? (
|
||||
// QR codes are server-generated. Blank → "will be generated"; an
|
||||
@@ -315,12 +380,44 @@ export function SubscriptionManager() {
|
||||
<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 }} />
|
||||
// RFID: the value is read off a physical card (or typed). "Read card"
|
||||
// arms a chosen reader and fills the captured value.
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} />
|
||||
)}
|
||||
{c.kind === "rf" && (
|
||||
<button type="button" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
|
||||
)}
|
||||
<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: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
|
||||
|
||||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||
the credential. The OTHER reader keeps serving the live flow. */}
|
||||
{capture && (
|
||||
<div style={{ marginTop: "0.5rem", padding: "0.6rem 0.75rem", border: "1px solid #0a7", borderRadius: 6, background: "#f0fbf6" }}>
|
||||
{capture.phase === "pick" ? (
|
||||
<>
|
||||
<div style={{ marginBottom: "0.35rem" }}>{t("subs.captureChooseReader")}</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||
{readers.length === 0 && <span style={{ color: "#a00" }}>{t("subs.captureNoReaders")}</span>}
|
||||
{readers.map((r) => (
|
||||
<button key={r.id} type="button" onClick={() => pickReader(r.id)}>
|
||||
{t(`devices.role.${r.direction}`)} ({r.driverId})
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<span>{capture.status ?? t("subs.captureWaiting")}</span>
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
{t("subs.needCredentialOrPlate")}
|
||||
</p>
|
||||
|
||||
@@ -337,6 +337,32 @@ export function createSubscription(body: SubscriptionInput): Promise<Subscriptio
|
||||
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
|
||||
}
|
||||
|
||||
// --- Credential capture ("enroll a card" on a chosen reader) ---------------
|
||||
|
||||
export interface ReaderInfo {
|
||||
id: string;
|
||||
driverId: string;
|
||||
direction: "entry" | "exit" | "both";
|
||||
}
|
||||
export type CaptureState =
|
||||
| { status: "idle" }
|
||||
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
|
||||
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
|
||||
| { status: "expired"; deviceId: string };
|
||||
|
||||
export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> {
|
||||
return apiFetch("/api/subscriptions/readers");
|
||||
}
|
||||
export function armCapture(deviceId: string): Promise<{ expiresAt: number }> {
|
||||
return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) });
|
||||
}
|
||||
export function pollCapture(): Promise<CaptureState> {
|
||||
return apiFetch("/api/subscriptions/capture");
|
||||
}
|
||||
export function cancelCapture(): Promise<{ ok: boolean }> {
|
||||
return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" });
|
||||
}
|
||||
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
|
||||
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
@@ -481,6 +507,10 @@ export interface SessionLookup {
|
||||
currency: string | null;
|
||||
withinGrace: boolean;
|
||||
graceExpiresAt: string | null;
|
||||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||
subscription: boolean;
|
||||
subscriptionId: string | null;
|
||||
subscriptionHolder: string | null;
|
||||
}
|
||||
|
||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||
@@ -500,6 +530,10 @@ export interface ActiveSession {
|
||||
currency: string | null;
|
||||
withinGrace: boolean;
|
||||
graceExpiresAt: string | null;
|
||||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||
subscription: boolean;
|
||||
subscriptionId: string | null;
|
||||
subscriptionHolder: string | null;
|
||||
}
|
||||
|
||||
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
||||
|
||||
@@ -85,6 +85,7 @@ export const en: Catalog = {
|
||||
badgeExiting: "exiting",
|
||||
badgePaid: "paid",
|
||||
badgeUnpaid: "unpaid",
|
||||
badgeSubscription: "subscription",
|
||||
evtEntry: "ENTRY",
|
||||
evtExit: "EXIT",
|
||||
evtPay: "PAY",
|
||||
@@ -156,6 +157,13 @@ export const en: Catalog = {
|
||||
credentialsCardQr: "Credentials (card / QR)",
|
||||
rfCardTag: "RF card/tag",
|
||||
rfCardTagSoon: "RF card/tag (soon)",
|
||||
rfPlaceholder: "card number (or read the card)",
|
||||
readCard: "Read card",
|
||||
captureChooseReader: "Choose a reader, then present the card:",
|
||||
captureNoReaders: "No readers configured.",
|
||||
captureWaiting: "Present the card to the reader…",
|
||||
captureTimeout: "Timed out with no card read. Try again.",
|
||||
captured: "Card read: {{value}}",
|
||||
qr: "QR",
|
||||
qrAutoGen: "QR code is auto-generated on save",
|
||||
credentialValue: "credential value",
|
||||
@@ -268,6 +276,11 @@ export const en: Catalog = {
|
||||
lookingUp: "looking up…",
|
||||
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||
subscription: "SUBSCRIPTION",
|
||||
plan: "Plan",
|
||||
prepaid: "PREPAID",
|
||||
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
|
||||
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
|
||||
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
||||
noSnapshots: "no snapshots",
|
||||
loadingSnapshots: "loading snapshots…",
|
||||
|
||||
@@ -79,13 +79,14 @@ export const sq = {
|
||||
inAt: "në",
|
||||
openPayExit: "Hap pagesën / daljen",
|
||||
openBarrier: "Hap barrierën",
|
||||
openBarrierTitle: "Hapje barriere me ndërhyrje njerëzore (e regjistruar)",
|
||||
openBarrierTitle: "Hap barrierën manualisht",
|
||||
barrierOpened: "barriera u hap",
|
||||
openManually: "hape me dorë",
|
||||
// session row badges
|
||||
badgeExiting: "duke dalë",
|
||||
badgePaid: "paguar",
|
||||
badgeUnpaid: "papaguar",
|
||||
badgeSubscription: "abonim",
|
||||
// event types (live feed labels)
|
||||
evtEntry: "HYRJE",
|
||||
evtExit: "DALJE",
|
||||
@@ -158,6 +159,13 @@ export const sq = {
|
||||
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||
rfCardTag: "Kartë/etiketë RF",
|
||||
rfCardTagSoon: "Kartë/etiketë RF (së shpejti)",
|
||||
rfPlaceholder: "numri i kartës (ose lexo kartën)",
|
||||
readCard: "Lexo kartën",
|
||||
captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:",
|
||||
captureNoReaders: "Asnjë lexues i konfiguruar.",
|
||||
captureWaiting: "Afro kartën te lexuesi…",
|
||||
captureTimeout: "Skadoi pa lexuar kartë. Provo sërish.",
|
||||
captured: "Karta u lexua: {{value}}",
|
||||
qr: "QR",
|
||||
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
|
||||
credentialValue: "vlera e kredencialit",
|
||||
@@ -270,6 +278,11 @@ export const sq = {
|
||||
lookingUp: "Duke kërkuar…",
|
||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||
subscription: "ABONIM",
|
||||
plan: "Plani",
|
||||
prepaid: "I PARAPAGUAR",
|
||||
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
|
||||
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||
// snapshots
|
||||
noSnapshots: "asnjë foto",
|
||||
|
||||
Reference in New Issue
Block a user