feat(shift): site-wide single-open shift + booth money-path gate
A shift becomes a SITE-WIDE accountability period — at most one open at a
time — so every taking is unambiguously attributed to one operator. Login
stays decoupled from shifts (an operator can log in off-shift to review).
Backend:
- ShiftService.currentOpenShift()/requireOpenShift(); open() refuses when ANY
shift is open and throws ShiftAlreadyOpenError{heldBy} (self vs. other).
- requireShift preHandler gates /api/pay, /api/exit, /api/voucher,
/api/barrier/reopen → 409 {code:"no_shift"}; read-only lookups stay open.
- GET /api/shift/current returns site-wide {open:{startedAt,operator},isMine}.
- GET /api/events?since=<iso> for per-shift log scoping (db: re-export gte).
Frontend:
- Header shift button: open / close-mine / disabled-when-another-holds-it.
- Pay/exit modal gate banner (one-click open; "held by X" when another's);
pay/exit/voucher disabled until this operator's shift is open.
- Active-Sessions barrier re-open gated the same way.
- Live feed scoped to the open shift's window; shared useShift() Query
invalidated over the WS on shift_open/shift_z_report/cash_movement.
- sq/en strings for the control + gate.
Wiki: shift.md (site-wide single-open + gate; superseded per-operator note),
booth-console.md (header control + gate), log entry.
Verified: site-wide invariant + heldBy + handover + chain integrity on a
fresh migrated DB (11/11); db/server/web build clean.
This commit is contained in:
@@ -3,6 +3,7 @@ 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, formatTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
|
||||
@@ -24,6 +25,10 @@ function statusBadge(s: ActiveSession): { key: string; cls: string } {
|
||||
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,
|
||||
@@ -97,10 +102,10 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
{s.paidAt ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending}
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={() => handleReopen(s)}
|
||||
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
|
||||
title={t("booth.openBarrierTitle")}
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
boothExit,
|
||||
fetchSiteConfig,
|
||||
lookupSession,
|
||||
openShift,
|
||||
paySession,
|
||||
printVoucher,
|
||||
type SessionLookup,
|
||||
} from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
|
||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
|
||||
@@ -28,18 +30,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
|
||||
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
|
||||
|
||||
// A shift must be open (and mine) before any pay/exit/voucher action — the booth
|
||||
// money path is gated. The server enforces this too (409 no_shift); the modal
|
||||
// surfaces it up front and offers a one-click open. See wiki/concepts/shift.md.
|
||||
const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift();
|
||||
const shiftReady = shiftOpen && shiftMine;
|
||||
|
||||
const [tender, setTender] = useState<"cash" | "card">("cash");
|
||||
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("review");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [openingShift, setOpeningShift] = useState(false);
|
||||
|
||||
const s: SessionLookup | undefined = session.data;
|
||||
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
||||
|
||||
const alreadyPaid = s?.paidAt != null;
|
||||
const canPay = s?.found && s.open && !alreadyPaid;
|
||||
const canPay = shiftReady && s?.found && s.open && !alreadyPaid;
|
||||
|
||||
async function handleOpenShift() {
|
||||
setOpeningShift(true);
|
||||
setError(null);
|
||||
try {
|
||||
await openShift();
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setOpeningShift(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePayAndExit() {
|
||||
if (!s) return;
|
||||
@@ -91,6 +114,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{/* Shift gate — block all actions until THIS operator has a shift open.
|
||||
Another operator's open shift can't be operated under (no shared
|
||||
till); only an "open mine" path when no shift is open at all. */}
|
||||
{!shiftReady && (
|
||||
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
||||
{blockedByOther ? (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateOtherTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">
|
||||
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenShift}
|
||||
disabled={openingShift}
|
||||
className="mt-2 rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{openingShift ? t("shift.opening") : t("shift.openNow")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
|
||||
|
||||
{s && !s.found && (
|
||||
@@ -200,7 +256,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={phase === "paying" || phase === "finishing"}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{phase === "paying"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useLiveStore } from "./lib/live-store.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
import { BoothPayModal } from "./BoothPayModal.js";
|
||||
@@ -124,9 +125,19 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
|
||||
export function BoothScreen() {
|
||||
const { t } = useTranslation();
|
||||
// Initial load via Query (also the fallback if the WS is briefly down).
|
||||
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
|
||||
// window (per-shift logs, not all history). When no shift is open, the feed is
|
||||
// empty and the operator is prompted to open one.
|
||||
const { isOpen: shiftOpen, startedAt: shiftStart } = useShift();
|
||||
|
||||
// Initial load via Query (also the fallback if the WS is briefly down). The events
|
||||
// query is scoped to the current shift's start so it never shows prior shifts.
|
||||
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: [...qk.events, shiftStart ?? "none"],
|
||||
queryFn: () => fetchEvents(100, shiftStart ?? undefined),
|
||||
enabled: shiftOpen,
|
||||
});
|
||||
|
||||
// The ticket currently open in the pay/exit modal (null = no modal).
|
||||
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
||||
@@ -138,10 +149,16 @@ export function BoothScreen() {
|
||||
// Prefer the live-pushed occupancy; fall back to the query.
|
||||
const occ = liveOcc ?? occQuery.data ?? null;
|
||||
|
||||
// Merge: live events first (newest), then the queried history, de-duped by id.
|
||||
// Merge: live events first (newest), then the queried history, de-duped by id —
|
||||
// then clip to the current shift window (the live store spans shifts; the feed
|
||||
// must not show events from before this shift's start). No shift → no feed.
|
||||
const seen = new Set(liveFeed.map((e) => e.id));
|
||||
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||
const events = [...liveFeed, ...history].slice(0, 200);
|
||||
const merged = [...liveFeed, ...history].slice(0, 200);
|
||||
const events =
|
||||
shiftOpen && shiftStart
|
||||
? merged.filter((e) => e.occurredAt >= shiftStart)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||
@@ -176,7 +193,9 @@ export function BoothScreen() {
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
{events.length === 0 ? (
|
||||
{!shiftOpen ? (
|
||||
<div className="text-term-amber">{t("shift.gateTitle")}</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
||||
) : (
|
||||
events.map((e) => <EventRow key={e.id} e={e} />)
|
||||
|
||||
+15
-4
@@ -317,8 +317,12 @@ export function deletePermit(id: string): Promise<void> {
|
||||
// --- Shifts ---------------------------------------------------------------
|
||||
|
||||
export interface ShiftStatus {
|
||||
/** The requesting (logged-in) operator. */
|
||||
operator: string;
|
||||
open: { startedAt: string } | null;
|
||||
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
||||
open: { startedAt: string; operator: string | null } | null;
|
||||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||
isMine: boolean;
|
||||
/** Live physical drawer balance (cash payments + cash movements). */
|
||||
drawerMinor: number;
|
||||
currency: string | null;
|
||||
@@ -394,9 +398,16 @@ export function fetchOccupancy(): Promise<Occupancy> {
|
||||
export type { LedgerEvent } from "@parking/shared";
|
||||
|
||||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||
* booth feed's initial load; live updates then arrive over the WS. */
|
||||
export function fetchEvents(limit = 100): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||
return apiFetch(`/api/events?limit=${limit}`);
|
||||
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||||
* scopes to events at/after that instant — the booth passes the current shift's
|
||||
* start so the feed shows ONLY this shift's activity. */
|
||||
export function fetchEvents(
|
||||
limit = 100,
|
||||
since?: string,
|
||||
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (since) qs.set("since", since);
|
||||
return apiFetch(`/api/events?${qs.toString()}`);
|
||||
}
|
||||
|
||||
// --- Booth: session lookup, payment, exit ---------------------------------
|
||||
|
||||
@@ -183,6 +183,20 @@ export const en: Catalog = {
|
||||
expectedDrawer: "Expected drawer:",
|
||||
printedToReceipt: "Printed to booth receipt.",
|
||||
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||
// Header shift control + the booth shift gate.
|
||||
headerNoShift: "No shift",
|
||||
headerOpen: "Open shift",
|
||||
headerClose: "Close shift",
|
||||
headerHeldBy: "Shift open — {{operator}}",
|
||||
headerHeldByShort: "Shift: {{operator}}",
|
||||
gateTitle: "Open a shift to process tickets",
|
||||
gateBody:
|
||||
"No shift is open. Open your shift so payments and exits are recorded against it.",
|
||||
gateOtherTitle: "The open shift belongs to another operator",
|
||||
gateOtherBody:
|
||||
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
|
||||
openNow: "Open shift now",
|
||||
opening: "Opening…",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Ticket",
|
||||
|
||||
@@ -28,7 +28,7 @@ export const sq = {
|
||||
site: "Vendi",
|
||||
},
|
||||
status: {
|
||||
live: "DREJTPËRDREJT",
|
||||
live: "LIVE",
|
||||
connecting: "DUKE U LIDHUR",
|
||||
offline: "JASHTË LINJE",
|
||||
},
|
||||
@@ -43,7 +43,7 @@ export const sq = {
|
||||
uncapped: "pa kufi",
|
||||
free: "lirë",
|
||||
lotFull: "● parkimi plot",
|
||||
liveFeed: "Aktiviteti i drejtpërdrejtë",
|
||||
liveFeed: "Aktiviteti live",
|
||||
events: "ngjarje",
|
||||
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||
activeSessions: "Sesionet aktive",
|
||||
@@ -185,6 +185,20 @@ export const sq = {
|
||||
expectedDrawer: "Arka e pritshme:",
|
||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||
// Header shift control + the booth shift gate.
|
||||
headerNoShift: "Asnjë turn",
|
||||
headerOpen: "Hap turnin",
|
||||
headerClose: "Mbyll turnin",
|
||||
headerHeldBy: "Turn i hapur nga {{operator}}",
|
||||
headerHeldByShort: "Turni: {{operator}}",
|
||||
gateTitle: "Hap një turn për të proceduar biletat",
|
||||
gateBody:
|
||||
"Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.",
|
||||
gateOtherTitle: "Turni i hapur i përket një operatori tjetër",
|
||||
gateOtherBody:
|
||||
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
|
||||
openNow: "Hap turnin tani",
|
||||
opening: "Duke hapur…",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Bileta",
|
||||
|
||||
@@ -25,4 +25,5 @@ export const qk = {
|
||||
events: ["events"] as const,
|
||||
activeSessions: ["active-sessions"] as const,
|
||||
siteConfig: ["site-config"] as const,
|
||||
shift: ["shift"] as const,
|
||||
} as const;
|
||||
|
||||
@@ -64,6 +64,15 @@ export function useLiveFeed(): void {
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
// A shift open/close (or a drawer movement) changes the header control
|
||||
// state and the per-shift log window — refresh the shift status too.
|
||||
if (
|
||||
msg.event.type === "shift_open" ||
|
||||
msg.event.type === "shift_z_report" ||
|
||||
msg.event.type === "cash_movement"
|
||||
) {
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
}
|
||||
} else if (msg.kind === "printer-status") {
|
||||
void qc.invalidateQueries({ queryKey: ["printers"] });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchShift, type ShiftStatus } from "../api.js";
|
||||
import { qk } from "./query.js";
|
||||
|
||||
// Shared shift status for the whole app — the header control, the booth screen's
|
||||
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
|
||||
// they never disagree about whether a shift is open and whose it is. A shift is a
|
||||
// SITE-WIDE single-open accountability period (at most one open at a time). The WS
|
||||
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
|
||||
// live without polling. See wiki/concepts/shift.md.
|
||||
|
||||
export interface ShiftState {
|
||||
/** Raw status from the server (null while loading / on error). */
|
||||
status: ShiftStatus | undefined;
|
||||
/** Is ANY shift open site-wide? */
|
||||
isOpen: boolean;
|
||||
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
|
||||
isMine: boolean;
|
||||
/** A shift is open but belongs to someone else — this operator is blocked. */
|
||||
blockedByOther: boolean;
|
||||
/** ISO start of the open shift, for scoping the per-shift log. */
|
||||
startedAt: string | null;
|
||||
/** Whoever holds the open shift (for "held by X" messaging). */
|
||||
heldBy: string | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useShift(): ShiftState {
|
||||
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
|
||||
const s = q.data;
|
||||
const isOpen = s?.open != null;
|
||||
const isMine = s?.isMine ?? false;
|
||||
return {
|
||||
status: s,
|
||||
isOpen,
|
||||
isMine,
|
||||
blockedByOther: isOpen && !isMine,
|
||||
startedAt: s?.open?.startedAt ?? null,
|
||||
heldBy: s?.open?.operator ?? null,
|
||||
isLoading: q.isLoading,
|
||||
};
|
||||
}
|
||||
+69
-2
@@ -6,12 +6,15 @@ import {
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Lang, SessionUser } from "./api.js";
|
||||
import { logout, setLanguagePref } from "./api.js";
|
||||
import { queryClient } from "./lib/query.js";
|
||||
import { closeShift, logout, openShift, setLanguagePref } from "./api.js";
|
||||
import { qk, queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
import { BoothScreen } from "./BoothScreen.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
@@ -82,6 +85,69 @@ function LanguageToggle({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
|
||||
* - another's shift open → disabled, labelled with who holds it (you can neither
|
||||
* open yours nor close theirs until they hand over).
|
||||
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
|
||||
*/
|
||||
function ShiftButton() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function act(kind: "open" | "close") {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
if (kind === "open") await openShift();
|
||||
else await closeShift();
|
||||
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled when another operator holds the shift (can't open or close).
|
||||
const label = blockedByOther
|
||||
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
||||
: isMine
|
||||
? t("shift.headerClose")
|
||||
: t("shift.headerOpen");
|
||||
const tone = blockedByOther
|
||||
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
||||
: isMine
|
||||
? "border-term-red text-term-red hover:bg-term-red/10"
|
||||
: "border-term-green text-term-green hover:bg-term-green/10";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || blockedByOther}
|
||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||
onClick={() => act(isMine ? "close" : "open")}
|
||||
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
|
||||
>
|
||||
{busy ? t("shift.opening") : label}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
)}
|
||||
{err && <span className="text-[10px] text-term-red">{err}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
const { user, setUser } = rootRoute.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
@@ -102,6 +168,7 @@ function RootLayout() {
|
||||
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
<span className="text-[11px] text-term-muted">
|
||||
|
||||
Reference in New Issue
Block a user