cce99aadfd
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)
Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
(h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
font utility to rem across the web app (~230 sites in 25 files + the
.label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
layout stays put, so chrome never clips; tall content scrolls its own container.
Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.
Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
options already in the Type filter.
Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
name; overstay keeps a row tint). Removed the now-redundant status filter; only
the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).
Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
opening + cash-taken = expected reads clearly. Money values no longer line-wrap.
Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
following row by one column — it now emits a full label+value pair.
Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
528 lines
25 KiB
TypeScript
528 lines
25 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
closeShift,
|
|
fetchEvents,
|
|
fetchShift,
|
|
fetchShiftReport,
|
|
fetchShifts,
|
|
openShift,
|
|
recordCashVoucher,
|
|
type ShiftReport,
|
|
type ShiftSummary,
|
|
type SessionUser,
|
|
} from "./api.js";
|
|
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
|
import { Modal } from "./ui/Modal.js";
|
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
|
import type { LedgerEvent } from "@parking/shared";
|
|
|
|
// Shift hub — a two-pane master/detail. LEFT: the open/CURRENT shift (when any) plus
|
|
// completed shifts, filterable by a timeframe preset and (admin) by operator. RIGHT: the
|
|
// selected shift's signed activity log (every ledger event in its window). The current
|
|
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
|
|
// each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own;
|
|
// an admin (shift:cash) sees all. See wiki/concepts/shift.md.
|
|
|
|
function money(minor: number, currency: string | null): string {
|
|
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
|
}
|
|
|
|
type Preset = "yesterday" | "week" | "month" | "custom" | "all";
|
|
|
|
/** A preset → an inclusive [from, to] date window (yyyy-mm-dd) over the shift START. */
|
|
function presetRange(p: Preset): { from: string; to: string } | null {
|
|
if (p === "all" || p === "custom") return null;
|
|
const now = new Date();
|
|
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
|
if (p === "yesterday") {
|
|
const y = new Date(now);
|
|
y.setDate(y.getDate() - 1);
|
|
return { from: iso(y), to: iso(y) };
|
|
}
|
|
const from = new Date(now);
|
|
from.setDate(from.getDate() - (p === "week" ? 7 : 30));
|
|
return { from: iso(from), to: iso(now) };
|
|
}
|
|
|
|
/** The CURRENT (open) shift, synthesized from the X-report so it lists alongside closed
|
|
* shifts. `id` is a sentinel; `open` marks it for the badge + the action pane. null when
|
|
* no shift is open (or not visible to the requester). */
|
|
function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; isMine: boolean; refetch: () => void } {
|
|
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
|
const report = useQuery({
|
|
queryKey: ["shift", "xreport"],
|
|
queryFn: fetchShiftReport,
|
|
enabled: status.data?.open != null,
|
|
});
|
|
const refetch = () => {
|
|
void status.refetch();
|
|
void report.refetch();
|
|
};
|
|
if (!status.data?.open || !report.data) return { current: null, isMine: status.data?.isMine ?? false, refetch };
|
|
const x = report.data;
|
|
return {
|
|
isMine: status.data.isMine,
|
|
refetch,
|
|
current: {
|
|
id: "__current__",
|
|
index: Number.MAX_SAFE_INTEGER,
|
|
operator: x.operator,
|
|
startedAt: x.startedAt,
|
|
endedAt: x.asOf,
|
|
cashTotalMinor: x.cashTotalMinor,
|
|
cardTotalMinor: x.cardTotalMinor,
|
|
currency: x.currency,
|
|
paymentCount: x.paymentCount,
|
|
ticketTotalMinor: x.ticketTotalMinor,
|
|
subscriptionTotalMinor: x.subscriptionTotalMinor,
|
|
subscriptionSalesMinor: x.subscriptionSalesMinor,
|
|
subscriptionWindowMinor: x.subscriptionWindowMinor,
|
|
openingFloatMinor: x.openingFloatMinor,
|
|
cashAddedMinor: x.cashAddedMinor,
|
|
cashRemovedMinor: x.cashRemovedMinor,
|
|
expectedDrawerMinor: x.expectedDrawerMinor,
|
|
open: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { user: SessionUser | null; canManage?: boolean; canVoucher?: boolean }) {
|
|
const { t } = useTranslation();
|
|
const [preset, setPreset] = useState<Preset>("week");
|
|
const [operator, setOperator] = useState("");
|
|
const [customFrom, setCustomFrom] = useState("");
|
|
const [customTo, setCustomTo] = useState("");
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
const { current, isMine, refetch: refetchCurrent } = useCurrentShift();
|
|
|
|
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
|
const applied = {
|
|
operator: operator.trim() || undefined,
|
|
from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined,
|
|
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
|
};
|
|
|
|
const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) });
|
|
const isAdmin = q.data?.scope === "all";
|
|
const closed = q.data?.shifts ?? [];
|
|
|
|
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
|
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
|
const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null;
|
|
|
|
// Default the selection to the current shift (if any), else the newest closed one.
|
|
useEffect(() => {
|
|
if (list.length === 0) setSelectedId(null);
|
|
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [q.data, current?.id]);
|
|
|
|
function refreshAll() {
|
|
void q.refetch();
|
|
refetchCurrent();
|
|
}
|
|
|
|
const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"];
|
|
|
|
// Fill the viewport like the booth: a fixed title + filters, then a two-pane area
|
|
// that takes the remaining height — the shift LIST and the activity LOG each scroll
|
|
// on their own rather than the whole page growing.
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-2">
|
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
|
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
|
</h1>
|
|
{/* No shift open → the only action is to start one (gated on shift:create). */}
|
|
{canManage && !current && (
|
|
<StartShiftButton onDone={refreshAll} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Filters: timeframe presets (everyone) + operator (admin only). */}
|
|
<div className="card mb-3 flex shrink-0 flex-wrap items-end gap-3 p-3">
|
|
<div className="field">
|
|
<span className="label">{t("shifts.timeframe")}</span>
|
|
<div className="flex flex-wrap gap-1">
|
|
{PRESETS.map((p) => (
|
|
<button key={p} type="button" className={`btn btn-sm ${preset === p ? "btn-primary" : ""}`} onClick={() => setPreset(p)}>
|
|
{t(`shifts.preset_${p}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{preset === "custom" && (
|
|
<>
|
|
<div className="field">
|
|
<span className="label">{t("shifts.filterFrom")}</span>
|
|
<input type="date" className="input w-40" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} />
|
|
</div>
|
|
<div className="field">
|
|
<span className="label">{t("shifts.filterTo")}</span>
|
|
<input type="date" className="input w-40" value={customTo} onChange={(e) => setCustomTo(e.target.value)} />
|
|
</div>
|
|
</>
|
|
)}
|
|
{isAdmin && (
|
|
<div className="field">
|
|
<span className="label">{t("shifts.operator")}</span>
|
|
<input className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{q.isError && (
|
|
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{t("shifts.loadFailed")}</div>
|
|
)}
|
|
|
|
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
|
|
panes scroll independently and fill the remaining height (like the booth). */}
|
|
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
|
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
|
{!q.isLoading && list.length === 0 && (
|
|
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
|
|
)}
|
|
{list.map((s) => (
|
|
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
|
))}
|
|
</div>
|
|
|
|
<div className="min-h-0 overflow-hidden rounded-term border border-term-border">
|
|
{selected ? (
|
|
<ShiftActivityLog
|
|
shift={selected}
|
|
isCurrent={!!selected.open}
|
|
isMine={isMine}
|
|
showOperator={isAdmin}
|
|
canManage={canManage}
|
|
canVoucher={canVoucher}
|
|
onChanged={refreshAll}
|
|
/>
|
|
) : (
|
|
<p className="px-3 py-6 text-center text-[0.75rem] text-term-muted">{t("shifts.selectAShift")}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|
const { t } = useTranslation();
|
|
const [busy, setBusy] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
async function start() {
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
await openShift();
|
|
onDone();
|
|
} catch (e) {
|
|
setErr((e as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
return (
|
|
<span className="flex items-center gap-2">
|
|
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
|
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
|
{busy ? t("shift.starting") : t("shift.startShift")}
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; open: boolean; selected: boolean; onClick: () => void }) {
|
|
const { t } = useTranslation();
|
|
const cur = s.currency;
|
|
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`card w-full p-2.5 text-left text-[0.75rem] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
|
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
|
{showOperator ? s.operator : when(s.startedAt)}
|
|
</span>
|
|
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
|
</div>
|
|
{showOperator && <div className="text-term-muted">{when(s.startedAt)}</div>}
|
|
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
|
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
|
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
|
<span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>
|
|
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ShiftActivityLog({
|
|
shift,
|
|
isCurrent,
|
|
isMine,
|
|
showOperator,
|
|
canManage,
|
|
canVoucher,
|
|
onChanged,
|
|
}: {
|
|
shift: ShiftSummary;
|
|
isCurrent: boolean;
|
|
isMine: boolean;
|
|
showOperator: boolean;
|
|
canManage: boolean;
|
|
canVoucher: boolean;
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
|
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
|
// (full signed payload + snapshots + chain provenance).
|
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
|
|
|
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
|
|
const q = useQuery({
|
|
queryKey: ["shift-events", shift.id, shift.endedAt],
|
|
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt),
|
|
refetchInterval: isCurrent ? 5000 : false,
|
|
});
|
|
const events = q.data?.events ?? [];
|
|
const cur = shift.currency;
|
|
|
|
// Fill the pane: a fixed header + a scrollable activity list (matches the booth feed).
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
|
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
|
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
|
{showOperator && `${shift.operator} · `}
|
|
{formatRelativeDateTime(shift.startedAt, t)}
|
|
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
|
</span>
|
|
{/* Actions live on the CURRENT shift's pane (when it's mine), each → a modal. */}
|
|
{isCurrent && isMine && canManage && (
|
|
<span className="flex flex-wrap gap-1.5">
|
|
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
|
{canVoucher && <button type="button" className="btn btn-sm" onClick={() => setModal("voucher")}>{t("shift.drawerVoucher")}</button>}
|
|
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[0.6875rem] tabular-nums sm:grid-cols-4">
|
|
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
|
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
|
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
|
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
|
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
|
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
|
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
|
<Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />
|
|
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
|
{q.isLoading && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("common.loading")}</p>}
|
|
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.noActivity")}</p>}
|
|
{events.map((e) => (
|
|
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
|
))}
|
|
</div>
|
|
|
|
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
|
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
|
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
|
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Action modals ---------------------------------------------------------
|
|
|
|
function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClose: () => void; onDone: () => void }) {
|
|
const { t } = useTranslation();
|
|
const [busy, setBusy] = useState(false);
|
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const cur = shift.currency;
|
|
|
|
async function confirm() {
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
setReport(await closeShift());
|
|
onDone();
|
|
} catch (e) {
|
|
setErr((e as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
|
|
{report ? (
|
|
// Result — the signed Z-report.
|
|
<div className="text-[0.8125rem] tabular-nums">
|
|
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
|
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
|
<span />
|
|
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
|
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
|
<span />
|
|
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
|
</div>
|
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
|
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
|
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
|
|
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
|
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
|
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
|
<Figure label={t("shift.expectedDrawer")} value={money(report.expectedDrawerMinor, report.currency)} bold />
|
|
</div>
|
|
<div className={report.printed ? "mt-2 text-term-green" : "mt-2 text-term-amber"}>
|
|
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
|
|
</div>
|
|
<div className="mt-3 flex justify-end">
|
|
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// Confirm — show the live takings (split by source) + drawer before closing.
|
|
<div className="text-[0.8125rem] tabular-nums">
|
|
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
|
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
|
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
|
<span />
|
|
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
|
</div>
|
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
|
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
|
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
|
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
|
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
|
<span />
|
|
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
|
</div>
|
|
{err && <p className="mt-2 text-[0.75rem] text-term-red">{err}</p>}
|
|
<div className="mt-3 flex justify-end gap-2">
|
|
<button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button>
|
|
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
|
|
{busy ? t("shift.ending") : t("shift.endShift")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function VoucherModal({ currency, onClose, onDone }: { currency: string | null; onClose: () => void; onDone: () => void }) {
|
|
const { t } = useTranslation();
|
|
const [amount, setAmount] = useState("");
|
|
const [reason, setReason] = useState("");
|
|
const [authName, setAuthName] = useState("");
|
|
const [authPassword, setAuthPassword] = useState("");
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
|
|
async function submit(type: "cash_in" | "cash_out") {
|
|
setMsg(null);
|
|
const major = Number(amount);
|
|
if (!Number.isFinite(major) || major <= 0) return setMsg(t("shift.enterPositive"));
|
|
if (!authName.trim() || !authPassword) return setMsg(t("shift.authRequired"));
|
|
try {
|
|
const r = await recordCashVoucher({ type, amountMinor: Math.round(major * 100), reason: reason.trim(), authorizedBy: authName.trim(), authorizerPassword: authPassword });
|
|
setMsg(t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }));
|
|
setAmount("");
|
|
setReason("");
|
|
setAuthPassword("");
|
|
onDone();
|
|
} catch (e) {
|
|
setMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
|
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
|
|
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
|
|
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
|
|
</div>
|
|
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
|
|
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
|
|
<div className="mt-1 flex justify-end gap-2">
|
|
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
|
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
|
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => submit("cash_out")}>{t("shift.mandatPagese")}</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function TakingsModal({ onClose }: { onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
|
const x = q.data;
|
|
return (
|
|
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
|
{!x ? (
|
|
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
|
) : (
|
|
<div className="text-[0.8125rem] tabular-nums">
|
|
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
|
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
|
<span />
|
|
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
|
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
|
<span />
|
|
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
|
</div>
|
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
|
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
|
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
|
|
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
|
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
|
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
|
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
|
|
</div>
|
|
<div className="mt-2 text-[0.6875rem] text-term-muted">{t("shift.xReportHint")}</div>
|
|
<div className="mt-3 flex justify-end">
|
|
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
|
return (
|
|
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
|
<span className={`whitespace-nowrap ${sub ? "text-term-muted/70" : "text-term-muted"}`}>{label}</span>
|
|
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
|
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|