diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index e467d1c..84f789d 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -23,10 +23,26 @@ interface LanguageBody { language: Lang; } +const THEMES = ["dark", "light"] as const; +type Theme = (typeof THEMES)[number]; +interface ThemeBody { + theme: Theme; +} + /** The session shape the SPA bootstraps from: identity + role + its permission * list (so the UI can gate nav/routes) + language. Role NAME is for display; the * permissions are the source of truth. */ -function sessionView(db: Db, user: { id: string; username: string; roleId: string; language: string }) { +function sessionView( + db: Db, + user: { + id: string; + username: string; + roleId: string; + language: string; + theme: string; + fullName?: string | null; + }, +) { const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get(); const permissions = [...permissionsFor(user.roleId)]; return { @@ -36,6 +52,8 @@ function sessionView(db: Db, user: { id: string; username: string; roleId: strin roleName: role?.name ?? user.roleId, permissions, language: user.language, + theme: user.theme, + fullName: user.fullName ?? null, }; } @@ -106,4 +124,19 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { return { language }; }, ); + + // Change MY own UI theme preference (any signed-in user). Persisted to the users + // row like `language`, so it's restored on the next login from any booth. + app.put<{ Body: ThemeBody }>( + "/api/auth/theme", + { preHandler: requireAuth }, + async (req, reply) => { + const theme = req.body?.theme; + if (!theme || !THEMES.includes(theme)) { + return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` }); + } + await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run(); + return { theme }; + }, + ); } diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index ff8e1cf..a9c5c74 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { requirePermission } from "../auth.js"; +import { requirePermission, roleHasPermissions } from "../auth.js"; import { InvalidCashMovementError, NoOpenShiftError, @@ -14,6 +14,14 @@ interface CashMovementBody { currency?: string; } +interface ShiftsQuery { + /** Filter to one operator (admin-only; non-admins are forced to themselves). */ + operator?: string; + /** ISO window over shift START time. */ + from?: string; + to?: string; +} + // Shift endpoints (manned mode). The operator is the logged-in user; a shift is // opened/closed explicitly (not time-based — see wiki/concepts/shift.md and // local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it. @@ -43,6 +51,23 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr }; }); + // Completed shift history. SCOPED by permission: + // - `shift:read` (operators) → own shifts only; operator/from/to params ignored. + // - `shift:cash` (admin-grade) → all operators, optionally filtered by + // `operator` and a `from`/`to` time window over each shift's START. + // This keeps one operator from reading another's takings while letting admins + // reconcile across the site. The data is the signed shift_z_report chain. + app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => { + const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]); + const q = req.query ?? {}; + // Non-admins are hard-scoped to themselves regardless of any operator param. + const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username; + const from = canSeeAll ? q.from?.trim() || undefined : undefined; + const to = canSeeAll ? q.to?.trim() || undefined : undefined; + const shifts = shift.listShifts({ operator, from, to }); + return { shifts, scope: canSeeAll ? "all" : "self" }; + }); + // Admin loads/removes physical drawer cash (the float). Signed cash_movement // event. ADMIN ONLY — an operator takes payments but cannot move the float. // amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md. diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts index 8967091..c15c665 100644 --- a/apps/server/src/routes/users.ts +++ b/apps/server/src/routes/users.ts @@ -21,12 +21,20 @@ import { permissionsFor, requirePermission } from "../auth.js"; // account takeover; deleting an admin is sabotage). Both are blocked below by // comparing permission SETS. An admin holds the full set, so it is unrestricted. -interface CreateBody { +// Optional profile metadata accepted on create/update. All nullable; "" is treated +// as "clear" (→ null). Trimmed before persisting. +interface ProfileBody { + fullName?: string | null; + phone?: string | null; + email?: string | null; + address?: string | null; +} +interface CreateBody extends ProfileBody { username: string; password: string; roleId: string; } -interface UpdateBody { +interface UpdateBody extends ProfileBody { username?: string; roleId?: string; } @@ -35,6 +43,20 @@ interface PasswordBody { } const MIN_PASSWORD = 8; +const PROFILE_FIELDS = ["fullName", "phone", "email", "address"] as const; + +/** Pull the optional profile fields out of a body → a patch of trimmed values + * ("" → null). Absent keys are omitted (so an update only touches what's sent). */ +function profilePatch(body: ProfileBody): Record { + const out: Record = {}; + for (const k of PROFILE_FIELDS) { + const v = body[k]; + if (v === undefined) continue; + const trimmed = typeof v === "string" ? v.trim() : ""; + out[k] = trimmed === "" ? null : trimmed; + } + return out; +} export async function userRoutes(app: FastifyInstance, db: Db): Promise { const readGuard = requirePermission("user:read"); @@ -54,8 +76,28 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { } /** A user row safe to return — never the password hash. */ - function publicUser(u: { id: string; username: string; roleId: string; language: string; createdAt: string }) { - return { id: u.id, username: u.username, roleId: u.roleId, language: u.language, createdAt: u.createdAt }; + function publicUser(u: { + id: string; + username: string; + roleId: string; + language: string; + createdAt: string; + fullName?: string | null; + phone?: string | null; + email?: string | null; + address?: string | null; + }) { + return { + id: u.id, + username: u.username, + roleId: u.roleId, + language: u.language, + createdAt: u.createdAt, + fullName: u.fullName ?? null, + phone: u.phone ?? null, + email: u.email ?? null, + address: u.address ?? null, + }; } /** True if `targetRoleId` grants any permission the caller's role does NOT hold, @@ -103,7 +145,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { } const id = randomUUID(); const passwordHash = await bcrypt.hash(password, 12); - db.insert(users).values({ id, username, passwordHash, roleId }).run(); + db.insert(users).values({ id, username, passwordHash, roleId, ...profilePatch(req.body) }).run(); const created = db.select().from(users).where(eq(users.id, id)).get()!; return reply.code(201).send(publicUser(created)); }); @@ -121,7 +163,9 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" }); } - const next: { username?: string; roleId?: string } = {}; + const next: { username?: string; roleId?: string } & Record = { + ...profilePatch(req.body ?? {}), + }; if (req.body?.username != null) { const username = req.body.username.trim(); if (!username) return reply.code(400).send({ error: "username cannot be empty" }); diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index e877433..9a1d465 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -38,6 +38,25 @@ export class NoShiftOpenError extends Error { } } +/** A COMPLETED shift, reconstructed from its signed `shift_z_report` (which carries + * all the figures in its payload). This is the unit of the shift-history feature. + * `id` is the z_report's ledger id (stable, for the UI list key / future deep-link). */ +export interface ShiftSummary { + readonly id: string; + readonly index: number; + readonly operator: string; + readonly startedAt: string; + readonly endedAt: string; + readonly cashTotalMinor: number; + readonly cardTotalMinor: number; + readonly currency: string | null; + readonly paymentCount: number; + readonly openingFloatMinor: number; + readonly cashAddedMinor: number; + readonly cashRemovedMinor: number; + readonly expectedDrawerMinor: number; +} + export interface ShiftReport { readonly operator: string; readonly startedAt: string; @@ -115,6 +134,62 @@ export class ShiftService { return last && last.type === "shift_open" ? last : null; } + /** + * COMPLETED shift history, newest first. Each closed shift is one signed + * `shift_z_report` whose payload already holds every figure, so this is a simple + * read of those rows (no re-summing). Optional filters: + * - operator: only this operator's shifts (the `identity` on the z_report). + * - from/to: ISO timestamps; keep shifts whose START falls in [from, to]. + * The open shift (no z_report yet) is intentionally excluded — it's not a + * completed accountability period. Use `currentOpenShift()` for the live one. + */ + listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] { + const rows = this.#db + .select() + .from(ledgerEvents) + .where(eq(ledgerEvents.type, "shift_z_report")) + .orderBy(ledgerEvents.index) + .all(); + + const out: ShiftSummary[] = []; + for (const r of rows) { + const pl = (r.payload ?? {}) as LedgerPayload & { + operator?: string; + startedAt?: string; + endedAt?: string; + cashTotalMinor?: number; + cardTotalMinor?: number; + paymentCount?: number; + openingFloatMinor?: number; + cashAddedMinor?: number; + cashRemovedMinor?: number; + expectedDrawerMinor?: number; + }; + const operator = pl.operator ?? r.identity ?? "?"; + const startedAt = pl.startedAt ?? r.occurredAt; + if (opts.operator && operator !== opts.operator) continue; + if (opts.from && startedAt < opts.from) continue; + if (opts.to && startedAt > opts.to) continue; + out.push({ + id: r.id, + index: r.index, + operator, + startedAt, + endedAt: pl.endedAt ?? r.occurredAt, + cashTotalMinor: pl.cashTotalMinor ?? 0, + cardTotalMinor: pl.cardTotalMinor ?? 0, + currency: pl.currency ?? null, + paymentCount: pl.paymentCount ?? 0, + openingFloatMinor: pl.openingFloatMinor ?? 0, + cashAddedMinor: pl.cashAddedMinor ?? 0, + cashRemovedMinor: pl.cashRemovedMinor ?? 0, + expectedDrawerMinor: pl.expectedDrawerMinor ?? 0, + }); + } + // Newest first for the history list. + return out.reverse(); + } + /** Require an open shift for the booth money path; returns it or throws. */ requireOpenShift() { const open = this.currentOpenShift(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7818abd..be3ab73 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import { fetchMe, type SessionUser } from "./api.js"; import { Login } from "./Login.js"; import { queryClient } from "./lib/query.js"; import { setLanguage } from "./lib/i18n/index.js"; +import { applyTheme } from "./lib/theme.js"; import { router } from "./router.js"; // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands @@ -23,10 +24,16 @@ export function App() { .finally(() => setLoading(false)); }, []); - // Apply the signed-in user's preferred language whenever it resolves/changes - // (login, bootstrap, or a toggle). Albanian is the default before auth resolves. + // Apply the signed-in user's preferred language + theme whenever they resolve/ + // change (login, bootstrap, or a toggle). Albanian + dark are the defaults before + // auth resolves; on logout, fall back to dark so the Login screen is consistent. useEffect(() => { - if (user) setLanguage(user.language); + if (user) { + setLanguage(user.language); + applyTheme(user.theme); + } else { + applyTheme("dark"); + } }, [user]); if (loading) { diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx new file mode 100644 index 0000000..07f8182 --- /dev/null +++ b/apps/web/src/ShiftsHistory.tsx @@ -0,0 +1,183 @@ +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 } 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. + +/** Local date + time (history spans days, so not just time-of-day). */ +function fmtDateTime(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +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 ( +
+
+

+ {isAdmin ? t("shifts.title") : t("shifts.myTitle")} +

+
+ + {/* Admin-only filter: by operator + a date window over the shift start. */} + {isAdmin && ( +
+
+ {t("shifts.operator")} + setOperator(e.target.value)} + placeholder={t("shifts.allOperators")} + /> +
+
+ {t("shifts.filterFrom")} + setFrom(e.target.value)} /> +
+
+ {t("shifts.filterTo")} + setTo(e.target.value)} /> +
+ + +
+ )} + + {q.isError && ( +
+ {t("shifts.loadFailed")} +
+ )} + +
+ + + + {isAdmin && } + + + + + + + + + + {shifts.map((s) => ( + + ))} + {!q.isLoading && shifts.length === 0 && ( + + + + )} + +
{t("shifts.operator")}{t("shifts.started")}{t("shifts.ended")}{t("shifts.payments")}{t("shifts.cash")}{t("shifts.card")}{t("shifts.expectedDrawer")}
+ {t("shifts.none")} +
+
+
+ ); +} + +function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator: boolean; colSpan: number }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const cur = s.currency; + + return ( + <> + setOpen((o) => !o)} + > + {showOperator && {s.operator}} + {fmtDateTime(s.startedAt)} + + {fmtDateTime(s.endedAt)} + {formatDuration(s.startedAt, s.endedAt)} + + {s.paymentCount} + {money(s.cashTotalMinor, cur)} + {money(s.cardTotalMinor, cur)} + {money(s.expectedDrawerMinor, cur)} + + {open && ( + + +
{t("shifts.drawerSection")}
+
+
+
+
+
+
+ + + )} + + ); +} + +function Figure({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/web/src/UsersManager.tsx b/apps/web/src/UsersManager.tsx index 7a5989e..9f74ee2 100644 --- a/apps/web/src/UsersManager.tsx +++ b/apps/web/src/UsersManager.tsx @@ -14,6 +14,7 @@ import { type ManagedUser, type SessionUser, } from "./api.js"; +import { Modal } from "./ui/Modal.js"; // User management (admin). List users, create one (username + password + role), // change a user's role, reset a password, delete. The server enforces the same @@ -33,6 +34,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) { const roles: ManagedRole[] = rolesQ.data?.roles ?? []; const [error, setError] = useState(null); const [adding, setAdding] = useState(false); + const [editingUser, setEditingUser] = useState(null); const invalidate = () => void qc.invalidateQueries({ queryKey: ["users"] }); const onError = (e: unknown) => @@ -43,11 +45,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {

{t("users.title")}

{canCreate && roles.length > 0 && ( - )} @@ -55,19 +53,51 @@ export function UsersManager({ user }: { user: SessionUser | null }) { {error &&
{error}
} - {adding && ( + setAdding(false)} title={t("users.new")} width="max-w-2xl"> setAdding(false)} onSubmit={async (v) => { try { - await createUser({ username: v.username, password: v.password, roleId: v.roleId }); + await createUser({ + username: v.username, + password: v.password!, + roleId: v.roleId, + fullName: v.fullName, + phone: v.phone, + email: v.email, + address: v.address, + }); setAdding(false); invalidate(); } catch (e) { onError(e); } }} /> - )} + + + setEditingUser(null)} title={t("users.editTitle")} width="max-w-2xl"> + {editingUser && ( + setEditingUser(null)} + onSubmit={async (v) => { + try { + await updateUser(editingUser.id, { + username: v.username, + roleId: v.roleId, + fullName: v.fullName, + phone: v.phone, + email: v.email, + address: v.address, + }); + setEditingUser(null); + invalidate(); + } catch (e) { onError(e); } + }} + /> + )} +
@@ -86,6 +116,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) { roles={roles} canUpdate={canUpdate} canDelete={canDelete} + onEdit={() => { setEditingUser(u); setError(null); }} onChanged={invalidate} onError={onError} /> @@ -101,12 +132,13 @@ export function UsersManager({ user }: { user: SessionUser | null }) { } function UserRow({ - u, roles, canUpdate, canDelete, onChanged, onError, + u, roles, canUpdate, canDelete, onEdit, onChanged, onError, }: { u: ManagedUser; roles: ManagedRole[]; canUpdate: boolean; canDelete: boolean; + onEdit: () => void; onChanged: () => void; onError: (e: unknown) => void; }) { @@ -132,13 +164,16 @@ function UserRow({ return ( - +
{u.username} + {u.username} + {u.fullName && {u.fullName}} + {canUpdate ? ( @@ -149,8 +184,12 @@ function UserRow({
{canUpdate && !resetting && ( - + )} + {canUpdate && !resetting && ( + )} @@ -160,21 +199,19 @@ function UserRow({ type="password" value={pw} autoFocus onChange={(e) => setPw(e.target.value)} placeholder={t("users.newPassword")} - className="w-32 rounded-term border border-term-border bg-term-panel px-2 py-0.5 text-[12px]" + className="input input-sm w-32" /> - - + )} {canDelete && ( @@ -185,47 +222,98 @@ function UserRow({ ); } +/** Submitted form value. `password` is omitted entirely on edit (a blank field must + * not blank the password — that's the separate "reset password" flow). */ +interface UserFormValue { + username: string; + password?: string; + roleId: string; + fullName: string; + phone: string; + email: string; + address: string; +} + function UserForm({ - roles, onCancel, onSubmit, + roles, editing, onCancel, onSubmit, }: { roles: ManagedRole[]; + /** When set, the form edits this user (username/role/details; NOT the password). */ + editing?: ManagedUser; onCancel: () => void; - onSubmit: (v: { username: string; password: string; roleId: string }) => void; + onSubmit: (v: UserFormValue) => void; }) { const { t } = useTranslation(); - const [username, setUsername] = useState(""); + const isEdit = editing != null; + const [username, setUsername] = useState(editing?.username ?? ""); const [password, setPassword] = useState(""); - const [roleId, setRoleId] = useState(roles[0]?.id ?? ""); - const valid = username.trim().length > 0 && password.length >= 8 && roleId; + const [roleId, setRoleId] = useState(editing?.roleId ?? roles[0]?.id ?? ""); + const [fullName, setFullName] = useState(editing?.fullName ?? ""); + const [phone, setPhone] = useState(editing?.phone ?? ""); + const [email, setEmail] = useState(editing?.email ?? ""); + const [address, setAddress] = useState(editing?.address ?? ""); + + // On create, a >=8 char password is required; on edit it's left untouched. + const valid = username.trim().length > 0 && roleId && (isEdit || password.length >= 8); + + function submit() { + onSubmit({ + username: username.trim(), + ...(isEdit ? {} : { password }), + roleId, + fullName, + phone, + email, + address, + }); + } return ( -
-
{t("users.new")}
-
- - -
+ {!isEdit && ( +
+ {t("users.password")} + setPassword(e.target.value)} /> +
+ )} +
+ {t("users.role")} + - +
-
{t("users.passwordHint")}
-
- - + {!isEdit &&
{t("users.passwordHint")}
} + + {/* Optional profile metadata. */} +
{t("users.detailsSection")}
+
+
+ {t("users.fullName")} + setFullName(e.target.value)} /> +
+
+ {t("users.phone")} + setPhone(e.target.value)} /> +
+
+ {t("users.email")} + setEmail(e.target.value)} /> +
+
+ {t("users.address")} + setAddress(e.target.value)} /> +
+
+ +
+ +
); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 870b2c8..e3c8d46 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -45,6 +45,7 @@ export class ApiError extends Error { // --- Auth ----------------------------------------------------------------- export type Lang = "sq" | "en"; +export type Theme = "dark" | "light"; /** A `resource:action` permission string (the server is the source of truth for * the full grid; the role composer fetches it via /api/roles). */ export type Permission = string; @@ -57,6 +58,10 @@ export interface SessionUser { permissions: Permission[]; /** Preferred UI language (loaded from the server on login). */ language: Lang; + /** Preferred UI theme (loaded from the server on login). */ + theme: Theme; + /** Optional display name (profile metadata); null if unset. */ + fullName: string | null; } /** Does this session grant the permission? Central authz check for the SPA. */ @@ -80,6 +85,11 @@ export function setLanguagePref(language: Lang): Promise<{ language: Lang }> { return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) }); } +/** Persist the current user's UI theme preference (restored on next login). */ +export function setThemePref(theme: Theme): Promise<{ theme: Theme }> { + return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) }); +} + /** Returns the current user, or null if not authenticated. */ export async function fetchMe(): Promise { try { @@ -92,7 +102,14 @@ export async function fetchMe(): Promise { // --- User & role management (RBAC) ---------------------------------------- -export interface ManagedUser { +/** Optional profile metadata on a managed user (all nullable). */ +export interface UserProfile { + fullName: string | null; + phone: string | null; + email: string | null; + address: string | null; +} +export interface ManagedUser extends UserProfile { id: string; username: string; roleId: string; @@ -111,10 +128,15 @@ export interface ManagedRole { export function fetchUsers(): Promise<{ users: ManagedUser[] }> { return apiFetch("/api/users"); } -export function createUser(body: { username: string; password: string; roleId: string }): Promise { +export function createUser( + body: { username: string; password: string; roleId: string } & Partial, +): Promise { return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) }); } -export function updateUser(id: string, body: { username?: string; roleId?: string }): Promise { +export function updateUser( + id: string, + body: { username?: string; roleId?: string } & Partial, +): Promise { return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) }); } export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> { @@ -517,6 +539,39 @@ export function recordCashMovement( }); } +/** A completed shift (reconstructed from its signed Z-report). */ +export interface ShiftSummary { + id: string; + index: number; + operator: string; + startedAt: string; + endedAt: string; + cashTotalMinor: number; + cardTotalMinor: number; + currency: string | null; + paymentCount: number; + openingFloatMinor: number; + cashAddedMinor: number; + cashRemovedMinor: number; + expectedDrawerMinor: number; +} + +/** Completed shift history. The server scopes by permission: operators get their + * own shifts only (filter args ignored); admins (shift:cash) get all, optionally + * filtered by operator + a from/to window over the shift start. `scope` echoes + * which the server applied, so the UI can show/hide the filter. */ +export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{ + shifts: ShiftSummary[]; + scope: "all" | "self"; +}> { + const qs = new URLSearchParams(); + if (params.operator) qs.set("operator", params.operator); + if (params.from) qs.set("from", params.from); + if (params.to) qs.set("to", params.to); + const q = qs.toString(); + return apiFetch(`/api/shifts${q ? `?${q}` : ""}`); +} + // --- Site config / occupancy ---------------------------------------------- export interface Occupancy { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 857f504..66627ad 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -355,3 +355,57 @@ body { } /* ============================================================ + LIGHT THEME + The booth defaults to dark (dark room), but a user may prefer light; the + choice is saved to their profile (users.theme) and applied on as + `.theme-light`. Every screen reads colour through the --color-term-* tokens, + so re-pointing them here re-skins the whole app. The TRM "paper/ink" scale + supplies the surfaces; accents are tuned a shade darker for contrast on white. + A few component-layer values are literal hex (input borders, the inset + "recessed" shadow, primary-button text) — those are overridden too so fields + and buttons keep their affordance on a light background. + ============================================================ */ +html.theme-light { + /* Surfaces — TRM paper scale (light → slightly darker for layering). */ + --color-term-bg: #fafaf7; /* paper */ + --color-term-panel: #f2f2ee; /* paper-2 */ + --color-term-panel-2: #e8e8e2; /* paper-3 */ + --color-term-border: #d2d2c8; + --color-term-muted: #5a5a53; /* ink-3 — readable secondary text */ + --color-term-text: #14171c; /* near-black ink */ + + /* Accents — a step darker than the dark-theme values for white-bg contrast. */ + --color-term-amber: #b8740a; + --color-term-green: #1f6a36; + --color-term-red: #c8331f; + --color-term-cyan: #1a4fa8; +} + +/* Component-layer literals that must flip for light (the rest read tokens). */ +html.theme-light .input, +html.theme-light .select, +html.theme-light .textarea { + border-color: #c2c2b8; + box-shadow: inset 0 1px 2px rgba(20, 23, 28, 0.08); +} +html.theme-light .input:hover, +html.theme-light .select:hover, +html.theme-light .textarea:hover { + border-color: #a8a89e; +} +html.theme-light .input:focus, +html.theme-light .select:focus, +html.theme-light .textarea:focus { + box-shadow: inset 0 1px 2px rgba(20, 23, 28, 0.08), 0 0 0 1px var(--color-term-amber); +} +html.theme-light .btn { + border-color: #c2c2b8; +} +html.theme-light .btn:hover:not(:disabled) { + background: #dcdcd4; + border-color: #a8a89e; +} +/* Filled buttons keep light text; primary uses dark-on-amber, kept legible. */ +html.theme-light .btn-primary { + color: #fafaf7; +} diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 50ab659..43d6058 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -11,6 +11,9 @@ export const en: Catalog = { close: "Close", save: "Save", none: "—", + themeDark: "dark", + themeLight: "light", + theme: "Theme", }, auth: { title: "Parking System", @@ -23,11 +26,13 @@ export const en: Catalog = { booth: "Booth", shift: "Shift", setup: "Setup", + devices: "Devices", tariff: "Tariff", subscriptions: "Subscriptions", site: "Site", users: "Users", roles: "Roles", + shifts: "Shifts", }, status: { live: "LIVE", @@ -101,7 +106,7 @@ export const en: Catalog = { }, tariff: { title: "Tariff", - noRateCard: "No rate card published yet — the pay station can't charge until you publish one.", + noRateCard: "No tariff published yet — the pay station can't charge until you publish one.", activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.", currency: "Currency", freeEntryGrace: "Free entry grace (min)", @@ -121,13 +126,13 @@ export const en: Catalog = { addBlock: "+ Add block", publishNewVersion: "Publish new version", publishing: "Publishing…", - publishedOk: "New tariff version published — it's now the active rate card.", - defaultCard: "Default card (always active)", + publishedOk: "New tariff version published — it's now the active rate.", + defaultCard: "Base rate (always active)", defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.", modeLadder: "Hourly ladder", modeFlat: "Flat price", tiersAdvanced: "Advanced: time & seasonal tiers", - tiersHint: "Optional. Add cards that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, the simple card is published.", + tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.", tierName: "Name", tierPriority: "Priority", tierCategory: "Category", @@ -145,6 +150,72 @@ export const en: Catalog = { dow6: "Sat", dow0: "Sun", }, + setup: { + title: "Setup", + intro: + "Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.", + catControllers: "Controllers (barriers + entry button)", + catReaders: "Readers (QR / RFID)", + catCameras: "Cameras (snapshot + plate)", + catPrinters: "Printers (tickets / vouchers)", + nounController: "controller", + nounReader: "reader", + nounCamera: "camera", + nounPrinter: "printer", + add: "+ Add {{noun}}", + addAnother: "+ Add another {{noun}}", + addTitle: "Add {{noun}}", + editTitle: "Edit {{noun}}", + needControllerFirst: "Add a controller first — a {{noun}} points at one of its relays.", + failedToLoad: "Failed to load setup: {{error}}", + loadingCatalog: "Loading device catalog…", + dirEntry: "Entry", + dirExit: "Exit", + dirBoth: "Both (entry + exit)", + inherits: "inherits {{direction}}", + warnTitle: "⚠ Saved, but action needed:", + dismiss: "Dismiss", + disabled: "(disabled)", + edit: "Edit", + remove: "Remove", + removing: "Removing…", + confirmRemove: "Remove this {{driver}} device?", + noRelaysSet: "no relays set", + unbound: "unbound", + noDrivers: "No drivers registered.", + chooseDevice: "Choose a device…", + scan: "Scan for controllers", + scanning: "Scanning…", + noControllersFound: "No controllers found on the LAN.", + use: "Use", + test: "Test connection", + testing: "Testing…", + saveConfigure: "Save & configure", + saveChanges: "Save changes", + saving: "Saving…", + cancel: "Cancel", + testFailed: "Test failed: {{error}}", + saveFailed: "Save failed: {{error}}", + deviceLabel: "Device:", + preconditionsOk: "● preconditions OK", + autoFixedOnSave: "(auto-fixed on save)", + backendPushIp: "Backend push IP", + chooseAddress: "Choose an address…", + onDeviceSubnet: "— on device subnet", + noNicOnSubnet: "⚠ no NIC on the device's subnet — the device may not reach the backend", + backendIpHint: "The address this device will POST input events to.", + relaysTitle: "Relays on this controller", + relaysHint: + "Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.", + relay: "Relay", + entryButtonTerminal: "Entry button on terminal", + addRelay: "+ Add relay", + whichBarrier: "Which barrier does this device serve?", + controller: "Controller", + choose: "Choose…", + relayLabel: "Relay {{relay}} ({{direction}})", + noRelaysConfigured: "This controller has no relays configured.", + }, subs: { title: "Subscriptions", unnamed: "(unnamed)", @@ -240,8 +311,16 @@ export const en: Catalog = { newPassword: "new password", role: "Role", resetPassword: "Reset password", + edit: "Edit", + editTitle: "Edit user", + save: "Save", delete: "Delete", confirmDelete: "Delete user \"{{name}}\"?", + detailsSection: "Details (optional)", + fullName: "Full name", + phone: "Phone", + email: "Email", + address: "Address", }, roles: { title: "Roles", @@ -304,6 +383,29 @@ export const en: Catalog = { openNow: "Open shift now", opening: "Opening…", }, + shifts: { + title: "Shift history", + myTitle: "My shifts", + none: "No closed shifts.", + operator: "Operator", + started: "Started", + ended: "Ended", + payments: "Payments", + cash: "Cash", + card: "Card", + expectedDrawer: "Expected drawer", + filterFrom: "From", + filterTo: "To", + allOperators: "All operators", + apply: "Apply", + clear: "Clear", + drawerSection: "Drawer", + openingFloat: "Opening float", + cashTaken: "Cash taken", + cashAdded: "Cash added", + cashRemoved: "Cash removed", + loadFailed: "Failed to load shifts.", + }, pay: { ticket: "Ticket", entry: "Entry", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index f9e9e92..0470ca6 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -11,6 +11,9 @@ export const sq = { close: "Mbyll", save: "Ruaj", none: "—", + themeDark: "errët", + themeLight: "çelët", + theme: "Tema", }, auth: { title: "Sistemi i Parkimit", @@ -23,11 +26,13 @@ export const sq = { booth: "Kabina", shift: "Turni", setup: "Konfigurimi", + devices: "Pajisjet", tariff: "Tarifa", subscriptions: "Abonimet", - site: "Vendi", + site: "Park", users: "Përdoruesit", roles: "Rolet", + shifts: "Turnet", }, status: { live: "LIVE", @@ -103,7 +108,7 @@ export const sq = { }, tariff: { title: "Tarifa", - noRateCard: "Asnjë kartë tarifore e publikuar — arka nuk mund të faturojë derisa të publikoni një.", + noRateCard: "Asnjë tarifë e publikuar — arka nuk mund të faturojë derisa të publikoni një.", activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.", currency: "Monedha", freeEntryGrace: "Periudha pa pagesë në hyrje (min)", @@ -123,13 +128,13 @@ export const sq = { addBlock: "+ Shto bllok", publishNewVersion: "Publiko version të ri", publishing: "Duke publikuar…", - publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.", - defaultCard: "Karta e parazgjedhur (gjithmonë aktive)", + publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.", + defaultCard: "Tarifa bazë (gjithmonë aktive)", defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.", modeLadder: "Shkallë orësh", modeFlat: "Çmim fiks", tiersAdvanced: "Të avancuara: nivele kohore & sezonale", - tiersHint: "Opsionale. Shto karta që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet karta e thjeshtë.", + tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.", tierName: "Emri", tierPriority: "Përparësia", tierCategory: "Kategoria", @@ -147,6 +152,79 @@ export const sq = { dow6: "Sht", dow0: "Die", }, + setup: { + title: "Konfigurimi", + intro: + "Shto fillimisht kontrolluesit e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.", + // Category titles + the singular noun used in buttons/modal titles. + catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)", + catReaders: "Lexuesit (QR / RFID)", + catCameras: "Kamerat (foto + targë)", + catPrinters: "Printerat (bileta / vouchera)", + nounController: "kontrollues", + nounReader: "lexues", + nounCamera: "kamerë", + nounPrinter: "printer", + add: "+ Shto {{noun}}", + addAnother: "+ Shto edhe një {{noun}}", + addTitle: "Shto {{noun}}", + editTitle: "Ndrysho {{noun}}", + needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.", + failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}", + loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…", + // Direction labels (relay direction + inherited binding). + dirEntry: "Hyrje", + dirExit: "Dalje", + dirBoth: "Hyrje + dalje", + inherits: "trashëgon {{direction}}", + // Warnings panel. + warnTitle: "⚠ U ruajt, por nevojitet veprim:", + dismiss: "Mbyll", + // Assignment row. + disabled: "(çaktivizuar)", + edit: "Ndrysho", + remove: "Hiq", + removing: "Duke hequr…", + confirmRemove: "Të hiqet kjo pajisje {{driver}}?", + noRelaysSet: "asnjë rele e caktuar", + unbound: "e palidhur", + // Device form. + noDrivers: "Asnjë drejtues i regjistruar.", + chooseDevice: "Zgjidh një pajisje…", + scan: "Skano për kontrollues", + scanning: "Duke skanuar…", + noControllersFound: "Asnjë kontrollues në LAN.", + use: "Përdor", + test: "Testo lidhjen", + testing: "Duke testuar…", + saveConfigure: "Ruaj & konfiguro", + saveChanges: "Ruaj ndryshimet", + saving: "Duke ruajtur…", + cancel: "Anulo", + testFailed: "Testi dështoi: {{error}}", + saveFailed: "Ruajtja dështoi: {{error}}", + deviceLabel: "Pajisja:", + preconditionsOk: "● parakushtet OK", + autoFixedOnSave: "(rregullohet vetë në ruajtje)", + backendPushIp: "IP-ja e backend-it", + chooseAddress: "Zgjidh një adresë…", + onDeviceSubnet: "— në subnetin e pajisjes", + noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in", + backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.", + // Relay editor. + relaysTitle: "Relet në këtë kontrollues", + relaysHint: + "Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.", + relay: "Rele", + entryButtonTerminal: "Butoni i hyrjes në terminalin", + addRelay: "+ Shto rele", + // Binding picker. + whichBarrier: "Cilën barrierë shërben kjo pajisje?", + controller: "Kontrolluesi", + choose: "Zgjidh…", + relayLabel: "Rele {{relay}} ({{direction}})", + noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.", + }, subs: { title: "Abonimet", unnamed: "(pa emër)", @@ -182,8 +260,8 @@ export const sq = { commaSeparatedOptional: "të ndara me presje (opsionale)", credentials: "Kredencialet", credentialsCardQr: "Kredencialet (kartë / QR)", - rfCardTag: "Kartë/etiketë RF", - rfCardTagSoon: "Kartë/etiketë RF (së shpejti)", + rfCardTag: "Kartë/Tag RF", + rfCardTagSoon: "Kartë/Tag RF (së shpejti)", rfPlaceholder: "numri i kartës (ose lexo kartën)", readCard: "Lexo kartën", captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:", @@ -242,8 +320,17 @@ export const sq = { newPassword: "fjalëkalim i ri", role: "Roli", resetPassword: "Rivendos fjalëkalimin", + edit: "Ndrysho", + editTitle: "Ndrysho përdoruesin", + save: "Ruaj", delete: "Fshi", confirmDelete: "Të fshihet përdoruesi \"{{name}}\"?", + // Optional profile metadata. + detailsSection: "Të dhënat (opsionale)", + fullName: "Emri i plotë", + phone: "Telefoni", + email: "Email", + address: "Adresa", }, roles: { title: "Rolet", @@ -306,6 +393,31 @@ export const sq = { openNow: "Hap turnin tani", opening: "Duke hapur…", }, + shifts: { + title: "Historiku i turneve", + myTitle: "Turnet e mia", + none: "Asnjë turn i mbyllur.", + operator: "Operatori", + started: "Filloi", + ended: "Mbaroi", + payments: "Pagesa", + cash: "Para", + card: "Kartë", + expectedDrawer: "Arka e pritshme", + // Filter (admin only). + filterFrom: "Nga", + filterTo: "Deri", + allOperators: "Të gjithë operatorët", + apply: "Apliko", + clear: "Pastro", + // Expanded drawer detail. + drawerSection: "Arka", + openingFloat: "Bilanci fillestar", + cashTaken: "Para të marra", + cashAdded: "Para të shtuara", + cashRemoved: "Para të hequra", + loadFailed: "Ngarkimi i turneve dështoi.", + }, pay: { ticket: "Bileta", entry: "Hyrja", diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts new file mode 100644 index 0000000..4e8c1ee --- /dev/null +++ b/apps/web/src/lib/theme.ts @@ -0,0 +1,14 @@ +import type { Theme } from "../api.js"; + +// Theme application. The whole UI reads colour through the --color-term-* tokens; +// the light palette lives in index.css under `html.theme-light`. Applying a theme is +// just toggling that class on . The active theme is the LOGGED-IN USER's stored +// preference (users.theme), applied via applyTheme() after auth resolves — mirroring +// how language works. Dark is the default before auth resolves. Printed tickets are +// unaffected (always Albanian, dark-agnostic). + +/** Apply a theme by toggling `theme-light` on . Dark is the absence of the + * class (the base tokens). No-op-safe to call repeatedly. */ +export function applyTheme(theme: Theme): void { + document.documentElement.classList.toggle("theme-light", theme === "light"); +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 1ae3a39..4b36c21 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -9,10 +9,11 @@ import { import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import type { Lang, Permission, SessionUser } from "./api.js"; -import { can, closeShift, logout, openShift, setLanguagePref } from "./api.js"; +import type { Lang, Permission, SessionUser, Theme } from "./api.js"; +import { can, closeShift, logout, openShift, setLanguagePref, setThemePref } from "./api.js"; import { qk, queryClient } from "./lib/query.js"; import { setLanguage } from "./lib/i18n/index.js"; +import { applyTheme } from "./lib/theme.js"; import { useLiveFeed } from "./lib/use-live-feed.js"; import { useShift } from "./lib/use-shift.js"; import { DeviceFooter } from "./ui/DeviceFooter.js"; @@ -25,6 +26,7 @@ import { ShiftControl } from "./ShiftControl.js"; import { SiteSettings } from "./SiteSettings.js"; import { UsersManager } from "./UsersManager.js"; import { RolesManager } from "./RolesManager.js"; +import { ShiftsHistory } from "./ShiftsHistory.js"; // Code-based TanStack Router (no file-based codegen — the app is small enough that // an explicit tree is clearer). The router context carries the signed-in user and @@ -51,6 +53,43 @@ function NavLink({ to, label }: { to: string; label: string }) { ); } +/** A tab inside the Setup layout. `exact` (activeOptions) so the Devices tab at + * `/setup` doesn't stay highlighted on the child tabs. */ +function SetupTab({ to, label, exact = false }: { to: string; label: string; exact?: boolean }) { + return ( + + {label} + + ); +} + +/** Setup layout — the config hub. Renders a permission-gated tab bar and the active + * tab's screen via . Each tab is a child route (its own URL + guard), so + * deep links and the back button work and a denied tab redirects to the booth. */ +function SetupLayout() { + const { user } = rootRoute.useRouteContext(); + const { t } = useTranslation(); + const show = (perm: Permission) => can(user, perm); + return ( +
+ + +
+ ); +} + /** SQ/EN toggle. Persists the choice to the user's profile (restored on next login) * and applies it immediately. Updates the router-context user so App re-syncs. */ function LanguageToggle({ @@ -88,6 +127,45 @@ function LanguageToggle({ ); } +/** Dark/light theme toggle. Same shape as the language toggle: applies instantly, + * persists to the user's profile, and updates the router-context user so App + * re-syncs. Restored on the next login from any booth. */ +function ThemeToggle({ + user, + setUser, +}: { + user: SessionUser; + setUser: (u: SessionUser | null) => void; +}) { + const { t } = useTranslation(); + async function pick(theme: Theme) { + if (theme === user.theme) return; + applyTheme(theme); // instant UI + setUser({ ...user, theme }); + try { + await setThemePref(theme); // persist + } catch { + /* non-fatal — the choice still applies this session */ + } + } + return ( +
+ {(["dark", "light"] as const).map((th) => ( + + ))} +
+ ); +} + /** * Header shift control — the site-wide single-open shift expressed as one button: * - no shift open → "Open shift" (enabled; opens this operator's shift) @@ -167,23 +245,28 @@ function RootLayout() {
{user && } {user && } + {user && } {user?.username} · {user?.roleName}