feat(shift): current shift in the list + modal actions; full-width layout everywhere
Shift screen: - The standalone ShiftControl block is gone from /shift. The open/CURRENT shift now appears at the TOP of the shift list (CURRENT badge, live figures synthesized from the X-report), unified with history. Selecting it shows its live activity log. - Shift ACTIONS moved into the current shift's detail pane, each opening a MODAL: End shift (confirm → signed Z-report result), drawer voucher (Mandat in/out), takings-so-far (X-report). When no shift is open, a Start-shift button shows. - The current shift's log auto-refreshes (5s); a closed shift is bounded by its window. /setup/shifts stays read-only history (no manage props). Deleted the now- orphaned ShiftControl.tsx. Layout: - Every screen is now full-width like /booth — stripped the per-screen `mx-auto max-w-*` caps (Logs, Subscriptions, Plans, Tariff, Users, Roles, Setup layout, Shifts). The shell <main> already provides padding. Build+lint 12/12 (i18n parity). Verified a live open shift surfaces as the CURRENT list entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -94,7 +94,7 @@ export function LogsViewer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("logs.title")}</h1>
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("logs.title")}</h1>
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => q.refetch()}>
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => q.refetch()}>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
|||||||
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
|
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("roles.title")}</h1>
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("roles.title")}</h1>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function SetupWizard() {
|
|||||||
const controllers = assignments.filter((a) => a.category === "access");
|
const controllers = assignments.filter((a) => a.category === "access");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
||||||
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
|
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
closeShift,
|
|
||||||
fetchShift,
|
|
||||||
fetchShiftReport,
|
|
||||||
openShift,
|
|
||||||
recordCashVoucher,
|
|
||||||
type ShiftReport,
|
|
||||||
type XReport,
|
|
||||||
} from "./api.js";
|
|
||||||
|
|
||||||
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
|
||||||
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
|
||||||
// totals + the DRAWER picture (opening float carried from the prior shift, cash
|
|
||||||
// taken/added/removed, expected drawer). Operators RAISE a drawer cash voucher
|
|
||||||
// (Mandat Arkëtimi / Mandat Pagese); an admin AUTHORIZES it with their password.
|
|
||||||
// Available to cashier/operator/admin (readonly has no shift).
|
|
||||||
|
|
||||||
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
|
||||||
|
|
||||||
export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [startedAt, setStartedAt] = useState<string | null>(null);
|
|
||||||
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
|
||||||
const [currency, setCurrency] = useState<string | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [report, setReport] = useState<ShiftReport | null>(null);
|
|
||||||
const [xReport, setXReport] = useState<XReport | null>(null);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Drawer-voucher form. Operator raises; an admin authorizes (name + password).
|
|
||||||
const [moveAmount, setMoveAmount] = useState("");
|
|
||||||
const [moveReason, setMoveReason] = useState("");
|
|
||||||
const [authName, setAuthName] = useState("");
|
|
||||||
const [authPassword, setAuthPassword] = useState("");
|
|
||||||
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
fetchShift()
|
|
||||||
.then((s) => {
|
|
||||||
setStartedAt(s.open?.startedAt ?? null);
|
|
||||||
setDrawerMinor(s.drawerMinor);
|
|
||||||
setCurrency(s.currency);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* readonly / not permitted — hide control */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
useEffect(refresh, []);
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
setBusy(true);
|
|
||||||
setErr(null);
|
|
||||||
setReport(null);
|
|
||||||
setXReport(null);
|
|
||||||
try {
|
|
||||||
const { startedAt } = await openShift();
|
|
||||||
setStartedAt(startedAt);
|
|
||||||
refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setErr((e as Error).message);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function end() {
|
|
||||||
setBusy(true);
|
|
||||||
setErr(null);
|
|
||||||
setXReport(null);
|
|
||||||
try {
|
|
||||||
const z = await closeShift();
|
|
||||||
setReport(z);
|
|
||||||
setStartedAt(null);
|
|
||||||
refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setErr((e as Error).message);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Mid-shift X-report: read-only "takings so far" (appends nothing). Re-fetched on
|
|
||||||
// each click so it's always current.
|
|
||||||
async function viewReport() {
|
|
||||||
setErr(null);
|
|
||||||
try {
|
|
||||||
setXReport(await fetchShiftReport());
|
|
||||||
} catch (e) {
|
|
||||||
setErr((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function voucher(type: "cash_in" | "cash_out") {
|
|
||||||
setMoveMsg(null);
|
|
||||||
const major = Number(moveAmount);
|
|
||||||
if (!Number.isFinite(major) || major <= 0) {
|
|
||||||
setMoveMsg(t("shift.enterPositive"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!authName.trim() || !authPassword) {
|
|
||||||
setMoveMsg(t("shift.authRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await recordCashVoucher({
|
|
||||||
type,
|
|
||||||
amountMinor: Math.round(major * 100),
|
|
||||||
reason: moveReason.trim(),
|
|
||||||
authorizedBy: authName.trim(),
|
|
||||||
authorizerPassword: authPassword,
|
|
||||||
});
|
|
||||||
setMoveAmount("");
|
|
||||||
setMoveReason("");
|
|
||||||
setAuthPassword("");
|
|
||||||
setMoveMsg(
|
|
||||||
t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }),
|
|
||||||
);
|
|
||||||
refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setMoveMsg((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="card mt-6 max-w-md p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-[13px]">
|
|
||||||
<strong className="uppercase tracking-wider text-term-muted">{t("shift.label")}</strong>
|
|
||||||
{startedAt ? (
|
|
||||||
<>
|
|
||||||
<span className="font-semibold text-term-green">{t("shift.open")}</span>
|
|
||||||
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
|
|
||||||
<button type="button" className="btn btn-sm" onClick={viewReport} disabled={busy}>
|
|
||||||
{t("shift.viewTakings")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
|
|
||||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="text-term-muted">{t("shift.notStarted")}</span>
|
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
|
||||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Live drawer balance (what's in the till right now / inherited). */}
|
|
||||||
{drawerMinor != null && (
|
|
||||||
<div className="mt-2 text-[12px] text-term-text">
|
|
||||||
{t("shift.drawer")} <strong className="tabular-nums">{money(drawerMinor, currency)}</strong>
|
|
||||||
{startedAt && <span className="text-term-muted"> {t("shift.openingFloatInherited")}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
|
||||||
|
|
||||||
{/* Drawer cash voucher: operator RAISES, an admin AUTHORIZES (name + password).
|
|
||||||
cash_in = Mandat Arkëtimi (pay-IN), cash_out = Mandat Pagese (pay-OUT). */}
|
|
||||||
{canVoucher && (
|
|
||||||
<div className="mt-4 border-t border-term-border pt-3">
|
|
||||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
|
|
||||||
{t("shift.drawerVoucher")}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
className="input w-28"
|
|
||||||
value={moveAmount}
|
|
||||||
onChange={(e) => setMoveAmount(e.target.value)}
|
|
||||||
placeholder={t("shift.amount")}
|
|
||||||
inputMode="decimal"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input min-w-36 flex-1"
|
|
||||||
value={moveReason}
|
|
||||||
onChange={(e) => setMoveReason(e.target.value)}
|
|
||||||
placeholder={t("shift.reasonPlaceholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Admin sign-off — the float can only move with an admin's authorization. */}
|
|
||||||
<div className="mt-2 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"
|
|
||||||
/>
|
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => voucher("cash_in")}>
|
|
||||||
{t("shift.mandatArketimi")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => voucher("cash_out")}>
|
|
||||||
{t("shift.mandatPagese")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
|
||||||
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mid-shift X-report — read-only "takings so far" (no event appended). */}
|
|
||||||
{xReport && (
|
|
||||||
<div className="mt-4 rounded-term border border-term-cyan/40 bg-term-bg p-3 text-[12px] tabular-nums">
|
|
||||||
<div className="font-semibold text-term-cyan">{t("shift.xReport")} — {xReport.operator}</div>
|
|
||||||
<div className="text-term-muted">
|
|
||||||
{t("shift.asOf")} {new Date(xReport.asOf).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-term-text">{t("shift.payments")} {xReport.paymentCount}</div>
|
|
||||||
<div className="text-term-text">{t("shift.cash")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
|
|
||||||
<div className="text-term-text">{t("shift.card")} {money(xReport.cardTotalMinor, xReport.currency)}</div>
|
|
||||||
<div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
|
|
||||||
<div className="text-term-text">{t("shift.openingFloat")} {money(xReport.openingFloatMinor, xReport.currency)}</div>
|
|
||||||
<div className="text-term-text">{t("shift.cashTaken")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
|
|
||||||
<div className="text-term-text">{t("shift.cashAdded")} {money(xReport.cashAddedMinor, xReport.currency)}</div>
|
|
||||||
<div className="text-term-text">{t("shift.cashRemoved")} {money(xReport.cashRemovedMinor, xReport.currency)}</div>
|
|
||||||
<div className="font-semibold text-term-text">
|
|
||||||
{t("shift.expectedDrawer")} {money(xReport.expectedDrawerMinor, xReport.currency)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[11px] text-term-muted">{t("shift.xReportHint")}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report && (
|
|
||||||
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
|
|
||||||
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
|
||||||
<div className="text-term-text">{t("shift.payments")} {report.paymentCount}</div>
|
|
||||||
<div className="text-term-text">{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
|
||||||
<div className="text-term-text">{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
|
||||||
<div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
|
|
||||||
<div className="text-term-text">{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
|
|
||||||
<div className="text-term-text">{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
|
|
||||||
<div className="text-term-text">{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
|
|
||||||
<div className="text-term-text">{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
|
|
||||||
<div className="font-semibold text-term-text">
|
|
||||||
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
|
|
||||||
</div>
|
|
||||||
<div className={report.printed ? "mt-1 text-term-green" : "mt-1 text-term-amber"}>
|
|
||||||
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+315
-91
@@ -1,15 +1,28 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchEvents, fetchShifts, type ShiftSummary, type SessionUser } from "./api.js";
|
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 { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { Modal } from "./ui/Modal.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
// Shift history — a two-pane master/detail. LEFT: the operator's (or all, for an admin)
|
// Shift hub — a two-pane master/detail. LEFT: the open/CURRENT shift (when any) plus
|
||||||
// completed shifts, filterable by a timeframe preset (yesterday / last week / last month /
|
// completed shifts, filterable by a timeframe preset and (admin) by operator. RIGHT: the
|
||||||
// custom) and, for an admin, by operator. RIGHT: the SELECTED shift's signed activity log
|
// selected shift's signed activity log (every ledger event in its window). The current
|
||||||
// (every ledger event in its [start, end] window). Scope is enforced SERVER-SIDE: an
|
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
|
||||||
// operator sees only their own shifts; an admin (shift:cash) sees all. See shift.md.
|
// 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 {
|
function money(minor: number, currency: string | null): string {
|
||||||
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
||||||
@@ -48,15 +61,54 @@ function presetRange(p: Preset): { from: string; to: string } | null {
|
|||||||
return { from: iso(from), to: iso(now) };
|
return { from: iso(from), to: iso(now) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
/** 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,
|
||||||
|
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 { t } = useTranslation();
|
||||||
const [preset, setPreset] = useState<Preset>("week");
|
const [preset, setPreset] = useState<Preset>("week");
|
||||||
const [operator, setOperator] = useState("");
|
const [operator, setOperator] = useState("");
|
||||||
const [customFrom, setCustomFrom] = useState("");
|
const [customFrom, setCustomFrom] = useState("");
|
||||||
const [customTo, setCustomTo] = useState("");
|
const [customTo, setCustomTo] = useState("");
|
||||||
const [selected, setSelected] = useState<ShiftSummary | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { current, isMine, refetch: refetchCurrent } = useCurrentShift();
|
||||||
|
|
||||||
// Resolve the active date window from the preset (or the custom inputs).
|
|
||||||
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
||||||
const applied = {
|
const applied = {
|
||||||
operator: operator.trim() || undefined,
|
operator: operator.trim() || undefined,
|
||||||
@@ -64,32 +116,38 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
|||||||
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const q = useQuery({
|
const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) });
|
||||||
queryKey: ["shifts", applied],
|
|
||||||
queryFn: () => fetchShifts(applied),
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAdmin = q.data?.scope === "all";
|
const isAdmin = q.data?.scope === "all";
|
||||||
const shifts = q.data?.shifts ?? [];
|
const closed = q.data?.shifts ?? [];
|
||||||
|
|
||||||
// Keep a selection valid as the list changes; default to the newest shift.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (shifts.length === 0) {
|
if (list.length === 0) setSelectedId(null);
|
||||||
setSelected(null);
|
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
|
||||||
} else if (!selected || !shifts.some((s) => s.id === selected.id)) {
|
|
||||||
setSelected(shifts[0]!);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [q.data]);
|
}, [q.data, current?.id]);
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
void q.refetch();
|
||||||
|
refetchCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"];
|
const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl">
|
<div>
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
||||||
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
|
{/* No shift open → the only action is to start one (gated on shift:create). */}
|
||||||
|
{canManage && !current && (
|
||||||
|
<StartShiftButton onDone={refreshAll} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters: timeframe presets (everyone) + operator (admin only). */}
|
{/* Filters: timeframe presets (everyone) + operator (admin only). */}
|
||||||
@@ -98,12 +156,7 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
|||||||
<span className="label">{t("shifts.timeframe")}</span>
|
<span className="label">{t("shifts.timeframe")}</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{PRESETS.map((p) => (
|
{PRESETS.map((p) => (
|
||||||
<button
|
<button key={p} type="button" className={`btn btn-sm ${preset === p ? "btn-primary" : ""}`} onClick={() => setPreset(p)}>
|
||||||
key={p}
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-sm ${preset === p ? "btn-primary" : ""}`}
|
|
||||||
onClick={() => setPreset(p)}
|
|
||||||
>
|
|
||||||
{t(`shifts.preset_${p}`)}
|
{t(`shifts.preset_${p}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -124,44 +177,37 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("shifts.operator")}</span>
|
<span className="label">{t("shifts.operator")}</span>
|
||||||
<input
|
<input className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
|
||||||
className="input w-44"
|
|
||||||
value={operator}
|
|
||||||
onChange={(e) => setOperator(e.target.value)}
|
|
||||||
placeholder={t("shifts.allOperators")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{q.isError && (
|
{q.isError && (
|
||||||
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">
|
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
|
||||||
{t("shifts.loadFailed")}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Two-pane: shift list (left) + selected shift's activity log (right). */}
|
{/* Two-pane: shift list (left) + selected shift's activity log (right). */}
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
||||||
{/* LEFT — shift list */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{!q.isLoading && shifts.length === 0 && (
|
{!q.isLoading && list.length === 0 && (
|
||||||
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
||||||
)}
|
)}
|
||||||
{shifts.map((s) => (
|
{list.map((s) => (
|
||||||
<ShiftCard
|
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||||
key={s.id}
|
|
||||||
s={s}
|
|
||||||
showOperator={isAdmin}
|
|
||||||
selected={selected?.id === s.id}
|
|
||||||
onClick={() => setSelected(s)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT — activity log for the selected shift */}
|
|
||||||
<div className="rounded-term border border-term-border">
|
<div className="rounded-term border border-term-border">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<ShiftActivityLog shift={selected} showOperator={isAdmin} />
|
<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-[12px] text-term-muted">{t("shifts.selectAShift")}</p>
|
<p className="px-3 py-6 text-center text-[12px] text-term-muted">{t("shifts.selectAShift")}</p>
|
||||||
)}
|
)}
|
||||||
@@ -171,17 +217,33 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShiftCard({
|
function StartShiftButton({ onDone }: { onDone: () => void }) {
|
||||||
s,
|
const { t } = useTranslation();
|
||||||
showOperator,
|
const [busy, setBusy] = useState(false);
|
||||||
selected,
|
const [err, setErr] = useState<string | null>(null);
|
||||||
onClick,
|
async function start() {
|
||||||
}: {
|
setBusy(true);
|
||||||
s: ShiftSummary;
|
setErr(null);
|
||||||
showOperator: boolean;
|
try {
|
||||||
selected: boolean;
|
await openShift();
|
||||||
onClick: () => void;
|
onDone();
|
||||||
}) {
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{err && <span className="text-[12px] 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 { t } = useTranslation();
|
||||||
const cur = s.currency;
|
const cur = s.currency;
|
||||||
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
||||||
@@ -189,12 +251,11 @@ function ShiftCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`card w-full p-2.5 text-left text-[12px] transition-colors ${
|
className={`card w-full p-2.5 text-left text-[12px] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
||||||
selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-semibold text-term-text">
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||||
|
{open && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
||||||
{showOperator ? s.operator : when(s.startedAt)}
|
{showOperator ? s.operator : when(s.startedAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||||
@@ -204,34 +265,59 @@ function ShiftCard({
|
|||||||
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
<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-green">{money(s.cashTotalMinor, cur)}</span>
|
||||||
<span className="text-term-cyan">{money(s.cardTotalMinor, 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")}>
|
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
||||||
{money(s.expectedDrawerMinor, cur)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShiftActivityLog({ shift, showOperator }: { shift: ShiftSummary; showOperator: boolean }) {
|
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 { t } = useTranslation();
|
||||||
// Every signed event in the shift's [start, end] window — the full audit trail.
|
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
||||||
|
|
||||||
|
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["shift-events", shift.id],
|
queryKey: ["shift-events", shift.id, shift.endedAt],
|
||||||
queryFn: () => fetchEvents(1000, shift.startedAt, shift.endedAt),
|
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt),
|
||||||
|
refetchInterval: isCurrent ? 5000 : false,
|
||||||
});
|
});
|
||||||
const events = q.data?.events ?? [];
|
const events = q.data?.events ?? [];
|
||||||
const cur = shift.currency;
|
const cur = shift.currency;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header — the shift's drawer reconciliation. */}
|
|
||||||
<div className="border-b border-term-border bg-term-panel-2 px-3 py-2">
|
<div className="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-[12px]">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
|
||||||
<span className="font-semibold text-term-text">
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||||
|
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
||||||
{showOperator && `${shift.operator} · `}
|
{showOperator && `${shift.operator} · `}
|
||||||
{formatRelativeDateTime(shift.startedAt, t)} → {formatRelativeDateTime(shift.endedAt, t)}
|
{formatRelativeDateTime(shift.startedAt, t)}
|
||||||
|
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-term-muted">{formatDuration(shift.startedAt, shift.endedAt)}</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>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
|
||||||
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
@@ -243,38 +329,176 @@ function ShiftActivityLog({ shift, showOperator }: { shift: ShiftSummary; showOp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Activity log */}
|
<div className="max-h-[62vh] overflow-y-auto">
|
||||||
<div className="max-h-[60vh] overflow-y-auto">
|
|
||||||
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
|
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
|
||||||
{!q.isLoading && events.length === 0 && (
|
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
|
||||||
<p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>
|
|
||||||
)}
|
|
||||||
{events.map((e) => (
|
{events.map((e) => (
|
||||||
<ActivityRow key={e.id} e={e} />
|
<ActivityRow key={e.id} e={e} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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>
|
</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-[13px] 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)} />
|
||||||
|
<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/drawer before closing.
|
||||||
|
<div className="text-[13px] 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.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
|
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||||
|
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||||
|
</div>
|
||||||
|
{err && <p className="mt-2 text-[12px] 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-[13px]">
|
||||||
|
<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-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
||||||
|
{msg && <div className="text-[12px] 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-[12px] text-term-muted">{t("common.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="text-[13px] 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)} />
|
||||||
|
<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-[11px] 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 ActivityRow({ e }: { e: LedgerEvent }) {
|
function ActivityRow({ e }: { e: LedgerEvent }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const style = EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
|
const style = EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
|
||||||
const time = new Date(e.occurredAt).toLocaleTimeString();
|
const time = new Date(e.occurredAt).toLocaleTimeString();
|
||||||
const p = e.payload ?? {};
|
const p = e.payload ?? {};
|
||||||
const amount =
|
const amount = typeof p.amountMinor === "number" && p.amountMinor !== 0 ? money(p.amountMinor, (p.currency as string) ?? null) : null;
|
||||||
typeof p.amountMinor === "number" && p.amountMinor !== 0
|
|
||||||
? money(p.amountMinor, (p.currency as string) ?? null)
|
|
||||||
: null;
|
|
||||||
// A short actor/context: the subscriber holder, the identity, or the session ref.
|
|
||||||
const actor = (e.subscriberLabel as string | undefined) ?? e.identity ?? (p.sessionRef as string | undefined) ?? "";
|
const actor = (e.subscriberLabel as string | undefined) ?? e.identity ?? (p.sessionRef as string | undefined) ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-t border-term-border/60 px-3 py-1.5 text-[12px] first:border-t-0">
|
<div className="flex items-center gap-2 border-t border-term-border/60 px-3 py-1.5 text-[12px] first:border-t-0">
|
||||||
<span className="w-16 shrink-0 tabular-nums text-term-muted">{time}</span>
|
<span className="w-16 shrink-0 tabular-nums text-term-muted">{time}</span>
|
||||||
<span className={`w-20 shrink-0 font-semibold uppercase ${style.color}`}>
|
<span className={`w-20 shrink-0 font-semibold uppercase ${style.color}`}>{style.labelKey ? t(style.labelKey) : e.type}</span>
|
||||||
{style.labelKey ? t(style.labelKey) : e.type}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-term-text" title={actor}>{actor}</span>
|
<span className="min-w-0 flex-1 truncate text-term-text" title={actor}>{actor}</span>
|
||||||
{amount && <span className="shrink-0 tabular-nums text-term-cyan">{amount}</span>}
|
{amount && <span className="shrink-0 tabular-nums text-term-cyan">{amount}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export function SubscriptionManager() {
|
|||||||
if (!subs) return null;
|
if (!subs) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
||||||
<ul className="mb-3 list-none p-0">
|
<ul className="mb-3 list-none p-0">
|
||||||
{subs.map((s) => (
|
{subs.map((s) => (
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export function SubscriptionPlansManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-2xl px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export function TariffComposer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
||||||
{!state?.active ? (
|
{!state?.active ? (
|
||||||
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
|
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function TariffLab() {
|
|||||||
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
|
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
|
||||||
<p className="hint mb-4">{t("lab.intro")}</p>
|
<p className="hint mb-4">{t("lab.intro")}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
|||||||
setError(e instanceof ApiError ? e.message : (e as Error).message);
|
setError(e instanceof ApiError ? e.message : (e as Error).message);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("users.title")}</h1>
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("users.title")}</h1>
|
||||||
{canCreate && roles.length > 0 && (
|
{canCreate && roles.length > 0 && (
|
||||||
|
|||||||
@@ -568,6 +568,7 @@ export const en: Catalog = {
|
|||||||
starting: "Starting…",
|
starting: "Starting…",
|
||||||
endShift: "End shift",
|
endShift: "End shift",
|
||||||
ending: "Ending…",
|
ending: "Ending…",
|
||||||
|
endConfirm: "End this shift? A signed Z-report is recorded and printed.",
|
||||||
drawer: "Drawer:",
|
drawer: "Drawer:",
|
||||||
openingFloatInherited: "(opening float inherited from the prior shift)",
|
openingFloatInherited: "(opening float inherited from the prior shift)",
|
||||||
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
|
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
|
||||||
@@ -640,6 +641,7 @@ export const en: Catalog = {
|
|||||||
preset_custom: "Custom",
|
preset_custom: "Custom",
|
||||||
selectAShift: "Select a shift to see its activity log.",
|
selectAShift: "Select a shift to see its activity log.",
|
||||||
noActivity: "No activity in this shift.",
|
noActivity: "No activity in this shift.",
|
||||||
|
current: "current",
|
||||||
drawerSection: "Drawer",
|
drawerSection: "Drawer",
|
||||||
openingFloat: "Opening float",
|
openingFloat: "Opening float",
|
||||||
cashTaken: "Cash taken",
|
cashTaken: "Cash taken",
|
||||||
|
|||||||
@@ -580,6 +580,7 @@ export const sq = {
|
|||||||
starting: "Duke filluar…",
|
starting: "Duke filluar…",
|
||||||
endShift: "Mbyll turnin",
|
endShift: "Mbyll turnin",
|
||||||
ending: "Duke mbyllur…",
|
ending: "Duke mbyllur…",
|
||||||
|
endConfirm: "Të mbyllet ky turn? Regjistrohet dhe printohet një Raport Z i nënshkruar.",
|
||||||
drawer: "Arka:",
|
drawer: "Arka:",
|
||||||
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||||
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||||
@@ -653,6 +654,7 @@ export const sq = {
|
|||||||
preset_custom: "E zgjedhur",
|
preset_custom: "E zgjedhur",
|
||||||
selectAShift: "Zgjidh një turn për të parë aktivitetin e tij.",
|
selectAShift: "Zgjidh një turn për të parë aktivitetin e tij.",
|
||||||
noActivity: "Asnjë aktivitet në këtë turn.",
|
noActivity: "Asnjë aktivitet në këtë turn.",
|
||||||
|
current: "aktual",
|
||||||
// Expanded drawer detail.
|
// Expanded drawer detail.
|
||||||
drawerSection: "Arka",
|
drawerSection: "Arka",
|
||||||
openingFloat: "Bilanci fillestar",
|
openingFloat: "Bilanci fillestar",
|
||||||
|
|||||||
+10
-13
@@ -24,7 +24,6 @@ import { TariffComposer } from "./TariffComposer.js";
|
|||||||
import { TariffLab } from "./TariffLab.js";
|
import { TariffLab } from "./TariffLab.js";
|
||||||
import { SubscriptionManager } from "./SubscriptionManager.js";
|
import { SubscriptionManager } from "./SubscriptionManager.js";
|
||||||
import { SubscriptionPlansManager } from "./SubscriptionPlansManager.js";
|
import { SubscriptionPlansManager } from "./SubscriptionPlansManager.js";
|
||||||
import { ShiftControl } from "./ShiftControl.js";
|
|
||||||
import { SiteSettings } from "./SiteSettings.js";
|
import { SiteSettings } from "./SiteSettings.js";
|
||||||
import { UsersManager } from "./UsersManager.js";
|
import { UsersManager } from "./UsersManager.js";
|
||||||
import { RolesManager } from "./RolesManager.js";
|
import { RolesManager } from "./RolesManager.js";
|
||||||
@@ -78,7 +77,7 @@ function SetupLayout() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const show = (perm: Permission) => can(user, perm);
|
const show = (perm: Permission) => can(user, perm);
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="">
|
||||||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
||||||
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
||||||
@@ -344,18 +343,16 @@ const shiftRoute = createRoute({
|
|||||||
path: "/shift",
|
path: "/shift",
|
||||||
component: function ShiftRoute() {
|
component: function ShiftRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
// Top: the shift CONTROL (open/close, drawer vouchers, X-report). The drawer-voucher
|
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
||||||
// form is operator-RAISED (shift:create); an admin authorizes with their password.
|
// The CURRENT shift's pane carries the actions (open/close, drawer voucher, takings),
|
||||||
// Below: the shift LIST + per-shift activity log (scoped server-side by permission).
|
// each opening a modal. `canManage` = shift:create (start/end + raise vouchers); a
|
||||||
|
// voucher additionally needs an admin's password sign-off server-side.
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-4 py-4">
|
<ShiftsHistory
|
||||||
<ShiftControl canVoucher={can(user, "shift:create")} />
|
user={user}
|
||||||
{can(user, "shift:read") && (
|
canManage={can(user, "shift:create")}
|
||||||
<div className="mt-6">
|
canVoucher={can(user, "shift:create")}
|
||||||
<ShiftsHistory user={user} />
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user