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
172 lines
7.1 KiB
TypeScript
172 lines
7.1 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { fetchShifts, type ShiftSummary, type SessionUser } from "./api.js";
|
|
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
|
|
|
// Completed shift history. Scope is enforced SERVER-SIDE by permission: an operator
|
|
// gets only their own shifts; an admin (shift:cash) gets all + a date/operator
|
|
// filter. The screen mirrors that — it shows the filter only when the server
|
|
// reports scope:"all". Each row is one signed shift_z_report; expanding it shows the
|
|
// drawer reconciliation. See wiki/concepts/shift.md.
|
|
|
|
function money(minor: number, currency: string | null): string {
|
|
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
|
}
|
|
|
|
export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
|
const { t } = useTranslation();
|
|
// Admin filter inputs (only sent when the server grants the "all" scope; for an
|
|
// operator the server ignores them anyway).
|
|
const [operator, setOperator] = useState("");
|
|
const [from, setFrom] = useState("");
|
|
const [to, setTo] = useState("");
|
|
// The applied filter (separate from the inputs, so typing doesn't refetch).
|
|
const [applied, setApplied] = useState<{ operator?: string; from?: string; to?: string }>({});
|
|
|
|
const q = useQuery({
|
|
queryKey: ["shifts", applied],
|
|
queryFn: () => fetchShifts(applied),
|
|
});
|
|
|
|
const isAdmin = q.data?.scope === "all";
|
|
const shifts = q.data?.shifts ?? [];
|
|
|
|
function apply() {
|
|
setApplied({
|
|
operator: operator.trim() || undefined,
|
|
// A date input gives yyyy-mm-dd; widen `to` to the end of that day.
|
|
from: from ? new Date(`${from}T00:00:00`).toISOString() : undefined,
|
|
to: to ? new Date(`${to}T23:59:59`).toISOString() : undefined,
|
|
});
|
|
}
|
|
function clear() {
|
|
setOperator("");
|
|
setFrom("");
|
|
setTo("");
|
|
setApplied({});
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-4xl">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
|
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
|
</h1>
|
|
</div>
|
|
|
|
{/* Admin-only filter: by operator + a date window over the shift start. */}
|
|
{isAdmin && (
|
|
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3">
|
|
<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 className="field">
|
|
<span className="label">{t("shifts.filterFrom")}</span>
|
|
<input type="date" className="input w-44" value={from} onChange={(e) => setFrom(e.target.value)} />
|
|
</div>
|
|
<div className="field">
|
|
<span className="label">{t("shifts.filterTo")}</span>
|
|
<input type="date" className="input w-44" value={to} onChange={(e) => setTo(e.target.value)} />
|
|
</div>
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={apply}>
|
|
{t("shifts.apply")}
|
|
</button>
|
|
<button type="button" className="btn btn-sm" onClick={clear}>
|
|
{t("shifts.clear")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{q.isError && (
|
|
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">
|
|
{t("shifts.loadFailed")}
|
|
</div>
|
|
)}
|
|
|
|
<div className="overflow-hidden rounded-term border border-term-border">
|
|
<table className="w-full text-[12px] tabular-nums">
|
|
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
|
<tr>
|
|
{isAdmin && <th className="px-3 py-1.5 text-left">{t("shifts.operator")}</th>}
|
|
<th className="px-3 py-1.5 text-left">{t("shifts.started")}</th>
|
|
<th className="px-3 py-1.5 text-left">{t("shifts.ended")}</th>
|
|
<th className="px-3 py-1.5 text-right">{t("shifts.payments")}</th>
|
|
<th className="px-3 py-1.5 text-right">{t("shifts.cash")}</th>
|
|
<th className="px-3 py-1.5 text-right">{t("shifts.card")}</th>
|
|
<th className="px-3 py-1.5 text-right">{t("shifts.expectedDrawer")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{shifts.map((s) => (
|
|
<ShiftRow key={s.id} s={s} showOperator={isAdmin} colSpan={isAdmin ? 7 : 6} />
|
|
))}
|
|
{!q.isLoading && shifts.length === 0 && (
|
|
<tr>
|
|
<td colSpan={isAdmin ? 7 : 6} className="px-3 py-3 text-term-muted">
|
|
{t("shifts.none")}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator: boolean; colSpan: number }) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const cur = s.currency;
|
|
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
|
|
|
return (
|
|
<>
|
|
<tr
|
|
className="cursor-pointer border-t border-term-border hover:bg-term-panel-2"
|
|
onClick={() => setOpen((o) => !o)}
|
|
>
|
|
{showOperator && <td className="px-3 py-1.5 text-term-text">{s.operator}</td>}
|
|
<td className="px-3 py-1.5">{when(s.startedAt)}</td>
|
|
<td className="px-3 py-1.5">
|
|
{when(s.endedAt)}
|
|
<span className="ml-2 text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
|
</td>
|
|
<td className="px-3 py-1.5 text-right">{s.paymentCount}</td>
|
|
<td className="px-3 py-1.5 text-right text-term-green">{money(s.cashTotalMinor, cur)}</td>
|
|
<td className="px-3 py-1.5 text-right text-term-cyan">{money(s.cardTotalMinor, cur)}</td>
|
|
<td className="px-3 py-1.5 text-right font-semibold">{money(s.expectedDrawerMinor, cur)}</td>
|
|
</tr>
|
|
{open && (
|
|
<tr className="border-t border-term-border/50 bg-term-bg">
|
|
<td colSpan={colSpan} className="px-3 py-2">
|
|
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("shifts.drawerSection")}</div>
|
|
<div className="mt-1 grid grid-cols-2 gap-x-8 gap-y-0.5 sm:grid-cols-4">
|
|
<Figure label={t("shifts.openingFloat")} value={money(s.openingFloatMinor, cur)} />
|
|
<Figure label={t("shifts.cashTaken")} value={money(s.cashTotalMinor, cur)} />
|
|
<Figure label={t("shifts.cashAdded")} value={money(s.cashAddedMinor, cur)} />
|
|
<Figure label={t("shifts.cashRemoved")} value={money(s.cashRemovedMinor, cur)} />
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Figure({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex justify-between gap-2">
|
|
<span className="text-term-muted">{label}</span>
|
|
<span className="text-term-text">{value}</span>
|
|
</div>
|
|
);
|
|
}
|