00f3d141b6
Dates were raw ISO on printed slips and time-only in the UI (a session from two days ago showed just "10:48"). Make them human and day-relative. Also fix a latent toggle bug surfaced while testing. Dates: - Printed tickets/receipts/subscription cards now show "19 Qershor 2026 10:48:25" (Albanian month, 24h with seconds) instead of YYYY-MM-DD HH:MM. stamp() exported as formatStampSq so the shift Z-report shares it. - Shift Z-report is now Albanian (Operatori/Nga/Deri/Para në dorë/Arka…), was English-only with ISO dates. - Web sessions/logs/history show relative days: "Sot 10:48" / "Dje 17:33" / "17 Qershor 10:48" via formatRelativeDateTime(). Month names come from the i18n catalog (common.months), NOT Intl — the appliance browser's ICU lacks Albanian locale data and Intl silently falls back to English month names. Toggle fix: - The language + theme toggles read the active value from the TanStack Router context `user`, which is captured at route-resolution time and does not re-render on setUser. After one switch the highlight froze and the equality guard blocked switching back until a page refresh. Drive them off live state instead: language from i18n.language (useTranslation subscribes to languageChanged), theme from local useState. (Bug dated to 040c0ff.) Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
131 lines
5.5 KiB
TypeScript
131 lines
5.5 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
|
import { qk } from "./lib/query.js";
|
|
import { useShift } from "./lib/use-shift.js";
|
|
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
|
import { Panel } from "./ui/Panel.js";
|
|
|
|
// Active Sessions panel. A session is "active" while still inside OR exited-but-
|
|
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
|
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
|
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
|
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
|
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
|
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
|
// 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" };
|
|
}
|
|
|
|
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
// The audited barrier re-open is a money-path action (server-gated on an open
|
|
// shift); disable it unless this operator's shift is open.
|
|
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
|
|
const shiftReady = shiftOpen && shiftMine;
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: qk.activeSessions,
|
|
queryFn: fetchActiveSessions,
|
|
// Belt-and-braces refresh in case a grace window expires with no ledger event
|
|
// to invalidate the cache (the WS only pushes on appends).
|
|
refetchInterval: 15_000,
|
|
});
|
|
|
|
const reopen = useMutation({
|
|
mutationFn: (identity: string) => reopenBarrier(identity),
|
|
onSettled: () => {
|
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
|
void qc.invalidateQueries({ queryKey: qk.events });
|
|
},
|
|
});
|
|
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
|
|
|
const sessions = data?.sessions ?? [];
|
|
|
|
async function handleReopen(s: ActiveSession) {
|
|
setReopenMsg(null);
|
|
try {
|
|
const r = await reopen.mutateAsync(s.identity);
|
|
setReopenMsg({
|
|
id: s.identity,
|
|
ok: r.opened,
|
|
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
|
|
});
|
|
} catch (e) {
|
|
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Panel
|
|
title={t("booth.activeSessions")}
|
|
right={
|
|
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
|
{sessions.length} {t("booth.insideCount")}
|
|
</span>
|
|
}
|
|
className="min-h-0"
|
|
>
|
|
<div className="h-full overflow-y-auto pr-1">
|
|
{sessions.length === 0 ? (
|
|
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
|
|
) : (
|
|
sessions.map((s) => {
|
|
const badge = statusBadge(s);
|
|
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
|
return (
|
|
<div
|
|
key={s.identity}
|
|
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => onPick(s.identity)}
|
|
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
|
title={t("booth.openPayExit")}
|
|
>
|
|
<span className="text-term-text">
|
|
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
|
</span>
|
|
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
|
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
|
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
|
</button>
|
|
|
|
{/* 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}
|
|
onClick={() => handleReopen(s)}
|
|
className="btn btn-pay btn-sm shrink-0"
|
|
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
|
>
|
|
{t("booth.openBarrier")}
|
|
</button>
|
|
) : (
|
|
<span className="w-[88px] shrink-0" />
|
|
)}
|
|
|
|
{msg && (
|
|
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
|
{msg.text}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|