feat: tabbed setup, user metadata, light theme, scoped shift history
Consolidate the config screens under a single /setup hub with permission- gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing the top nav to Booth·Shift·Setup; old top-level paths redirect. Users: add optional profile metadata (full name, phone, email, address) on create/edit. Theme: a light palette saved to the user's profile (users.theme), toggled in the header beside the language switch and applied on load like the language preference. Both ride on a single additive migration (0008). Shift history: a new GET /api/shifts folds the signed shift_z_report chain into completed shifts, SCOPED server-side — operators see only their own; holders of shift:cash see all with an operator + date-range filter. Surfaced as the Shifts tab; an operator cannot read another operator's takings (param spoofing is ignored). These three features share the router, api client and i18n catalogs, so they land together. Verified live: theme persists across reload, metadata round- trips to the DB, and shift scoping holds (operator self-only, admin all+filter). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -23,10 +23,26 @@ interface LanguageBody {
|
|||||||
language: Lang;
|
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
|
/** 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
|
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||||
* permissions are the source of truth. */
|
* 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 role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||||
const permissions = [...permissionsFor(user.roleId)];
|
const permissions = [...permissionsFor(user.roleId)];
|
||||||
return {
|
return {
|
||||||
@@ -36,6 +52,8 @@ function sessionView(db: Db, user: { id: string; username: string; roleId: strin
|
|||||||
roleName: role?.name ?? user.roleId,
|
roleName: role?.name ?? user.roleId,
|
||||||
permissions,
|
permissions,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
|
theme: user.theme,
|
||||||
|
fullName: user.fullName ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,4 +124,19 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return { language };
|
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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
import {
|
import {
|
||||||
InvalidCashMovementError,
|
InvalidCashMovementError,
|
||||||
NoOpenShiftError,
|
NoOpenShiftError,
|
||||||
@@ -14,6 +14,14 @@ interface CashMovementBody {
|
|||||||
currency?: string;
|
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
|
// 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
|
// 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.
|
// 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
|
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
|
||||||
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
|
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
|
||||||
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
|
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
|
||||||
|
|||||||
@@ -21,12 +21,20 @@ import { permissionsFor, requirePermission } from "../auth.js";
|
|||||||
// account takeover; deleting an admin is sabotage). Both are blocked below by
|
// 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.
|
// 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;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
roleId: string;
|
roleId: string;
|
||||||
}
|
}
|
||||||
interface UpdateBody {
|
interface UpdateBody extends ProfileBody {
|
||||||
username?: string;
|
username?: string;
|
||||||
roleId?: string;
|
roleId?: string;
|
||||||
}
|
}
|
||||||
@@ -35,6 +43,20 @@ interface PasswordBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MIN_PASSWORD = 8;
|
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<string, string | null> {
|
||||||
|
const out: Record<string, string | null> = {};
|
||||||
|
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<void> {
|
export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
const readGuard = requirePermission("user:read");
|
const readGuard = requirePermission("user:read");
|
||||||
@@ -54,8 +76,28 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A user row safe to return — never the password hash. */
|
/** A user row safe to return — never the password hash. */
|
||||||
function publicUser(u: { id: string; username: string; roleId: string; language: string; createdAt: string }) {
|
function publicUser(u: {
|
||||||
return { id: u.id, username: u.username, roleId: u.roleId, language: u.language, createdAt: u.createdAt };
|
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,
|
/** 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<void> {
|
|||||||
}
|
}
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
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()!;
|
const created = db.select().from(users).where(eq(users.id, id)).get()!;
|
||||||
return reply.code(201).send(publicUser(created));
|
return reply.code(201).send(publicUser(created));
|
||||||
});
|
});
|
||||||
@@ -121,7 +163,9 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" });
|
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<string, string | null> = {
|
||||||
|
...profilePatch(req.body ?? {}),
|
||||||
|
};
|
||||||
if (req.body?.username != null) {
|
if (req.body?.username != null) {
|
||||||
const username = req.body.username.trim();
|
const username = req.body.username.trim();
|
||||||
if (!username) return reply.code(400).send({ error: "username cannot be empty" });
|
if (!username) return reply.code(400).send({ error: "username cannot be empty" });
|
||||||
|
|||||||
@@ -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 {
|
export interface ShiftReport {
|
||||||
readonly operator: string;
|
readonly operator: string;
|
||||||
readonly startedAt: string;
|
readonly startedAt: string;
|
||||||
@@ -115,6 +134,62 @@ export class ShiftService {
|
|||||||
return last && last.type === "shift_open" ? last : null;
|
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. */
|
/** Require an open shift for the booth money path; returns it or throws. */
|
||||||
requireOpenShift() {
|
requireOpenShift() {
|
||||||
const open = this.currentOpenShift();
|
const open = this.currentOpenShift();
|
||||||
|
|||||||
+10
-3
@@ -5,6 +5,7 @@ import { fetchMe, type SessionUser } from "./api.js";
|
|||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
|
import { applyTheme } from "./lib/theme.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
|
||||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
@@ -23,10 +24,16 @@ export function App() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language whenever it resolves/changes
|
// Apply the signed-in user's preferred language + theme whenever they resolve/
|
||||||
// (login, bootstrap, or a toggle). Albanian is the default before auth resolves.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (user) setLanguage(user.language);
|
if (user) {
|
||||||
|
setLanguage(user.language);
|
||||||
|
applyTheme(user.theme);
|
||||||
|
} else {
|
||||||
|
applyTheme("dark");
|
||||||
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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;
|
||||||
|
|
||||||
|
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">{fmtDateTime(s.startedAt)}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{fmtDateTime(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+135
-47
@@ -14,6 +14,7 @@ import {
|
|||||||
type ManagedUser,
|
type ManagedUser,
|
||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
|
||||||
// User management (admin). List users, create one (username + password + role),
|
// User management (admin). List users, create one (username + password + role),
|
||||||
// change a user's role, reset a password, delete. The server enforces the same
|
// 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 roles: ManagedRole[] = rolesQ.data?.roles ?? [];
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||||
|
|
||||||
const invalidate = () => void qc.invalidateQueries({ queryKey: ["users"] });
|
const invalidate = () => void qc.invalidateQueries({ queryKey: ["users"] });
|
||||||
const onError = (e: unknown) =>
|
const onError = (e: unknown) =>
|
||||||
@@ -43,11 +45,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
|||||||
<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 && (
|
||||||
<button
|
<button type="button" className="btn btn-go btn-sm" onClick={() => { setAdding(true); setError(null); }}>
|
||||||
type="button"
|
|
||||||
onClick={() => { setAdding(true); setError(null); }}
|
|
||||||
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green"
|
|
||||||
>
|
|
||||||
{t("users.add")}
|
{t("users.add")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -55,19 +53,51 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
|||||||
|
|
||||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
||||||
|
|
||||||
{adding && (
|
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
|
||||||
<UserForm
|
<UserForm
|
||||||
roles={roles}
|
roles={roles}
|
||||||
onCancel={() => setAdding(false)}
|
onCancel={() => setAdding(false)}
|
||||||
onSubmit={async (v) => {
|
onSubmit={async (v) => {
|
||||||
try {
|
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);
|
setAdding(false);
|
||||||
invalidate();
|
invalidate();
|
||||||
} catch (e) { onError(e); }
|
} catch (e) { onError(e); }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={editingUser != null} onClose={() => setEditingUser(null)} title={t("users.editTitle")} width="max-w-2xl">
|
||||||
|
{editingUser && (
|
||||||
|
<UserForm
|
||||||
|
roles={roles}
|
||||||
|
editing={editingUser}
|
||||||
|
onCancel={() => 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); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-term border border-term-border">
|
<div className="overflow-hidden rounded-term border border-term-border">
|
||||||
<table className="w-full text-[12px]">
|
<table className="w-full text-[12px]">
|
||||||
@@ -86,6 +116,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
|||||||
roles={roles}
|
roles={roles}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
|
onEdit={() => { setEditingUser(u); setError(null); }}
|
||||||
onChanged={invalidate}
|
onChanged={invalidate}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
@@ -101,12 +132,13 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserRow({
|
function UserRow({
|
||||||
u, roles, canUpdate, canDelete, onChanged, onError,
|
u, roles, canUpdate, canDelete, onEdit, onChanged, onError,
|
||||||
}: {
|
}: {
|
||||||
u: ManagedUser;
|
u: ManagedUser;
|
||||||
roles: ManagedRole[];
|
roles: ManagedRole[];
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onError: (e: unknown) => void;
|
onError: (e: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -132,13 +164,16 @@ function UserRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-t border-term-border">
|
<tr className="border-t border-term-border">
|
||||||
<td className="px-3 py-1.5">{u.username}</td>
|
<td className="px-3 py-1.5">
|
||||||
|
{u.username}
|
||||||
|
{u.fullName && <span className="ml-2 text-term-muted">{u.fullName}</span>}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5">
|
||||||
{canUpdate ? (
|
{canUpdate ? (
|
||||||
<select
|
<select
|
||||||
value={u.roleId}
|
value={u.roleId}
|
||||||
onChange={(e) => roleMut.mutate(e.target.value)}
|
onChange={(e) => roleMut.mutate(e.target.value)}
|
||||||
className="rounded-term border border-term-border bg-term-panel px-2 py-0.5 text-[12px]"
|
className="select input-sm w-auto"
|
||||||
>
|
>
|
||||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -149,8 +184,12 @@ function UserRow({
|
|||||||
<td className="px-3 py-1.5 text-right">
|
<td className="px-3 py-1.5 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{canUpdate && !resetting && (
|
{canUpdate && !resetting && (
|
||||||
<button type="button" onClick={() => setResetting(true)}
|
<button type="button" className="btn btn-ghost btn-sm" onClick={onEdit}>
|
||||||
className="text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
{t("users.edit")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canUpdate && !resetting && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setResetting(true)}>
|
||||||
{t("users.resetPassword")}
|
{t("users.resetPassword")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -160,21 +199,19 @@ function UserRow({
|
|||||||
type="password" value={pw} autoFocus
|
type="password" value={pw} autoFocus
|
||||||
onChange={(e) => setPw(e.target.value)}
|
onChange={(e) => setPw(e.target.value)}
|
||||||
placeholder={t("users.newPassword")}
|
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"
|
||||||
/>
|
/>
|
||||||
<button type="button" disabled={pw.length < 8 || pwMut.isPending} onClick={() => pwMut.mutate()}
|
<button type="button" className="btn btn-go btn-sm" disabled={pw.length < 8 || pwMut.isPending} onClick={() => pwMut.mutate()}>
|
||||||
className="text-[11px] uppercase tracking-wider text-term-green disabled:opacity-40">
|
|
||||||
{t("common.save")}
|
{t("common.save")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => { setResetting(false); setPw(""); }}
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setResetting(false); setPw(""); }}>✕</button>
|
||||||
className="text-[11px] uppercase tracking-wider text-term-muted">✕</button>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
onClick={() => { if (confirm(t("users.confirmDelete", { name: u.username }))) delMut.mutate(); }}
|
onClick={() => { if (confirm(t("users.confirmDelete", { name: u.username }))) delMut.mutate(); }}
|
||||||
className="text-[11px] uppercase tracking-wider text-term-red hover:text-term-text"
|
|
||||||
>
|
>
|
||||||
{t("users.delete")}
|
{t("users.delete")}
|
||||||
</button>
|
</button>
|
||||||
@@ -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({
|
function UserForm({
|
||||||
roles, onCancel, onSubmit,
|
roles, editing, onCancel, onSubmit,
|
||||||
}: {
|
}: {
|
||||||
roles: ManagedRole[];
|
roles: ManagedRole[];
|
||||||
|
/** When set, the form edits this user (username/role/details; NOT the password). */
|
||||||
|
editing?: ManagedUser;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (v: { username: string; password: string; roleId: string }) => void;
|
onSubmit: (v: UserFormValue) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [username, setUsername] = useState("");
|
const isEdit = editing != null;
|
||||||
|
const [username, setUsername] = useState(editing?.username ?? "");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [roleId, setRoleId] = useState(roles[0]?.id ?? "");
|
const [roleId, setRoleId] = useState(editing?.roleId ?? roles[0]?.id ?? "");
|
||||||
const valid = username.trim().length > 0 && password.length >= 8 && roleId;
|
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 (
|
return (
|
||||||
<div className="mb-3 rounded-term border border-term-border bg-term-panel p-3">
|
<div>
|
||||||
<div className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber">{t("users.new")}</div>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="field">
|
||||||
<label className="text-[11px] text-term-muted">
|
<span className="label">{t("users.username")}</span>
|
||||||
{t("users.username")}
|
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||||
<input value={username} onChange={(e) => setUsername(e.target.value)}
|
</div>
|
||||||
className="mt-1 w-full rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
|
{!isEdit && (
|
||||||
</label>
|
<div className="field">
|
||||||
<label className="text-[11px] text-term-muted">
|
<span className="label">{t("users.password")}</span>
|
||||||
{t("users.password")}
|
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
</div>
|
||||||
className="mt-1 w-full rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
|
)}
|
||||||
</label>
|
<div className="field">
|
||||||
<label className="text-[11px] text-term-muted">
|
<span className="label">{t("users.role")}</span>
|
||||||
{t("users.role")}
|
<select className="select" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||||
<select value={roleId} onChange={(e) => setRoleId(e.target.value)}
|
|
||||||
className="mt-1 w-full rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text">
|
|
||||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[10px] text-term-muted">{t("users.passwordHint")}</div>
|
</div>
|
||||||
<div className="mt-2 flex justify-end gap-2">
|
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
|
||||||
<button type="button" onClick={onCancel}
|
|
||||||
className="rounded-term border border-term-border px-3 py-1 text-[12px] uppercase tracking-wider text-term-muted">{t("common.cancel")}</button>
|
{/* Optional profile metadata. */}
|
||||||
<button type="button" disabled={!valid} onClick={() => onSubmit({ username: username.trim(), password, roleId })}
|
<div className="mt-4 mb-2 text-[11px] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
|
||||||
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green disabled:opacity-40">{t("common.save")}</button>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("users.fullName")}</span>
|
||||||
|
<input className="input" value={fullName} onChange={(e) => setFullName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("users.phone")}</span>
|
||||||
|
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("users.email")}</span>
|
||||||
|
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("users.address")}</span>
|
||||||
|
<input className="input" value={address} onChange={(e) => setAddress(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={submit}>{t("common.save")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+58
-3
@@ -45,6 +45,7 @@ export class ApiError extends Error {
|
|||||||
// --- Auth -----------------------------------------------------------------
|
// --- Auth -----------------------------------------------------------------
|
||||||
|
|
||||||
export type Lang = "sq" | "en";
|
export type Lang = "sq" | "en";
|
||||||
|
export type Theme = "dark" | "light";
|
||||||
/** A `resource:action` permission string (the server is the source of truth for
|
/** A `resource:action` permission string (the server is the source of truth for
|
||||||
* the full grid; the role composer fetches it via /api/roles). */
|
* the full grid; the role composer fetches it via /api/roles). */
|
||||||
export type Permission = string;
|
export type Permission = string;
|
||||||
@@ -57,6 +58,10 @@ export interface SessionUser {
|
|||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
/** Preferred UI language (loaded from the server on login). */
|
/** Preferred UI language (loaded from the server on login). */
|
||||||
language: Lang;
|
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. */
|
/** 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 }) });
|
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. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +102,14 @@ export async function fetchMe(): Promise<SessionUser | null> {
|
|||||||
|
|
||||||
// --- User & role management (RBAC) ----------------------------------------
|
// --- 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;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
roleId: string;
|
roleId: string;
|
||||||
@@ -111,10 +128,15 @@ export interface ManagedRole {
|
|||||||
export function fetchUsers(): Promise<{ users: ManagedUser[] }> {
|
export function fetchUsers(): Promise<{ users: ManagedUser[] }> {
|
||||||
return apiFetch("/api/users");
|
return apiFetch("/api/users");
|
||||||
}
|
}
|
||||||
export function createUser(body: { username: string; password: string; roleId: string }): Promise<ManagedUser> {
|
export function createUser(
|
||||||
|
body: { username: string; password: string; roleId: string } & Partial<UserProfile>,
|
||||||
|
): Promise<ManagedUser> {
|
||||||
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
export function updateUser(id: string, body: { username?: string; roleId?: string }): Promise<ManagedUser> {
|
export function updateUser(
|
||||||
|
id: string,
|
||||||
|
body: { username?: string; roleId?: string } & Partial<UserProfile>,
|
||||||
|
): Promise<ManagedUser> {
|
||||||
return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> {
|
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 ----------------------------------------------
|
// --- Site config / occupancy ----------------------------------------------
|
||||||
|
|
||||||
export interface Occupancy {
|
export interface Occupancy {
|
||||||
|
|||||||
@@ -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 <html> 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;
|
||||||
|
}
|
||||||
|
|||||||
+106
-4
@@ -11,6 +11,9 @@ export const en: Catalog = {
|
|||||||
close: "Close",
|
close: "Close",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
none: "—",
|
none: "—",
|
||||||
|
themeDark: "dark",
|
||||||
|
themeLight: "light",
|
||||||
|
theme: "Theme",
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
title: "Parking System",
|
title: "Parking System",
|
||||||
@@ -23,11 +26,13 @@ export const en: Catalog = {
|
|||||||
booth: "Booth",
|
booth: "Booth",
|
||||||
shift: "Shift",
|
shift: "Shift",
|
||||||
setup: "Setup",
|
setup: "Setup",
|
||||||
|
devices: "Devices",
|
||||||
tariff: "Tariff",
|
tariff: "Tariff",
|
||||||
subscriptions: "Subscriptions",
|
subscriptions: "Subscriptions",
|
||||||
site: "Site",
|
site: "Site",
|
||||||
users: "Users",
|
users: "Users",
|
||||||
roles: "Roles",
|
roles: "Roles",
|
||||||
|
shifts: "Shifts",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
live: "LIVE",
|
live: "LIVE",
|
||||||
@@ -101,7 +106,7 @@ export const en: Catalog = {
|
|||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "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.",
|
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
|
||||||
currency: "Currency",
|
currency: "Currency",
|
||||||
freeEntryGrace: "Free entry grace (min)",
|
freeEntryGrace: "Free entry grace (min)",
|
||||||
@@ -121,13 +126,13 @@ export const en: Catalog = {
|
|||||||
addBlock: "+ Add block",
|
addBlock: "+ Add block",
|
||||||
publishNewVersion: "Publish new version",
|
publishNewVersion: "Publish new version",
|
||||||
publishing: "Publishing…",
|
publishing: "Publishing…",
|
||||||
publishedOk: "New tariff version published — it's now the active rate card.",
|
publishedOk: "New tariff version published — it's now the active rate.",
|
||||||
defaultCard: "Default card (always active)",
|
defaultCard: "Base rate (always active)",
|
||||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||||
modeLadder: "Hourly ladder",
|
modeLadder: "Hourly ladder",
|
||||||
modeFlat: "Flat price",
|
modeFlat: "Flat price",
|
||||||
tiersAdvanced: "Advanced: time & seasonal tiers",
|
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",
|
tierName: "Name",
|
||||||
tierPriority: "Priority",
|
tierPriority: "Priority",
|
||||||
tierCategory: "Category",
|
tierCategory: "Category",
|
||||||
@@ -145,6 +150,72 @@ export const en: Catalog = {
|
|||||||
dow6: "Sat",
|
dow6: "Sat",
|
||||||
dow0: "Sun",
|
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: {
|
subs: {
|
||||||
title: "Subscriptions",
|
title: "Subscriptions",
|
||||||
unnamed: "(unnamed)",
|
unnamed: "(unnamed)",
|
||||||
@@ -240,8 +311,16 @@ export const en: Catalog = {
|
|||||||
newPassword: "new password",
|
newPassword: "new password",
|
||||||
role: "Role",
|
role: "Role",
|
||||||
resetPassword: "Reset password",
|
resetPassword: "Reset password",
|
||||||
|
edit: "Edit",
|
||||||
|
editTitle: "Edit user",
|
||||||
|
save: "Save",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
confirmDelete: "Delete user \"{{name}}\"?",
|
confirmDelete: "Delete user \"{{name}}\"?",
|
||||||
|
detailsSection: "Details (optional)",
|
||||||
|
fullName: "Full name",
|
||||||
|
phone: "Phone",
|
||||||
|
email: "Email",
|
||||||
|
address: "Address",
|
||||||
},
|
},
|
||||||
roles: {
|
roles: {
|
||||||
title: "Roles",
|
title: "Roles",
|
||||||
@@ -304,6 +383,29 @@ export const en: Catalog = {
|
|||||||
openNow: "Open shift now",
|
openNow: "Open shift now",
|
||||||
opening: "Opening…",
|
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: {
|
pay: {
|
||||||
ticket: "Ticket",
|
ticket: "Ticket",
|
||||||
entry: "Entry",
|
entry: "Entry",
|
||||||
|
|||||||
+119
-7
@@ -11,6 +11,9 @@ export const sq = {
|
|||||||
close: "Mbyll",
|
close: "Mbyll",
|
||||||
save: "Ruaj",
|
save: "Ruaj",
|
||||||
none: "—",
|
none: "—",
|
||||||
|
themeDark: "errët",
|
||||||
|
themeLight: "çelët",
|
||||||
|
theme: "Tema",
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
title: "Sistemi i Parkimit",
|
title: "Sistemi i Parkimit",
|
||||||
@@ -23,11 +26,13 @@ export const sq = {
|
|||||||
booth: "Kabina",
|
booth: "Kabina",
|
||||||
shift: "Turni",
|
shift: "Turni",
|
||||||
setup: "Konfigurimi",
|
setup: "Konfigurimi",
|
||||||
|
devices: "Pajisjet",
|
||||||
tariff: "Tarifa",
|
tariff: "Tarifa",
|
||||||
subscriptions: "Abonimet",
|
subscriptions: "Abonimet",
|
||||||
site: "Vendi",
|
site: "Park",
|
||||||
users: "Përdoruesit",
|
users: "Përdoruesit",
|
||||||
roles: "Rolet",
|
roles: "Rolet",
|
||||||
|
shifts: "Turnet",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
live: "LIVE",
|
live: "LIVE",
|
||||||
@@ -103,7 +108,7 @@ export const sq = {
|
|||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tarifa",
|
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.",
|
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
|
||||||
currency: "Monedha",
|
currency: "Monedha",
|
||||||
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
|
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
|
||||||
@@ -123,13 +128,13 @@ export const sq = {
|
|||||||
addBlock: "+ Shto bllok",
|
addBlock: "+ Shto bllok",
|
||||||
publishNewVersion: "Publiko version të ri",
|
publishNewVersion: "Publiko version të ri",
|
||||||
publishing: "Duke publikuar…",
|
publishing: "Duke publikuar…",
|
||||||
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
|
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
||||||
defaultCard: "Karta e parazgjedhur (gjithmonë 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.",
|
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",
|
modeLadder: "Shkallë orësh",
|
||||||
modeFlat: "Çmim fiks",
|
modeFlat: "Çmim fiks",
|
||||||
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
|
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",
|
tierName: "Emri",
|
||||||
tierPriority: "Përparësia",
|
tierPriority: "Përparësia",
|
||||||
tierCategory: "Kategoria",
|
tierCategory: "Kategoria",
|
||||||
@@ -147,6 +152,79 @@ export const sq = {
|
|||||||
dow6: "Sht",
|
dow6: "Sht",
|
||||||
dow0: "Die",
|
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: {
|
subs: {
|
||||||
title: "Abonimet",
|
title: "Abonimet",
|
||||||
unnamed: "(pa emër)",
|
unnamed: "(pa emër)",
|
||||||
@@ -182,8 +260,8 @@ export const sq = {
|
|||||||
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
||||||
credentials: "Kredencialet",
|
credentials: "Kredencialet",
|
||||||
credentialsCardQr: "Kredencialet (kartë / QR)",
|
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||||
rfCardTag: "Kartë/etiketë RF",
|
rfCardTag: "Kartë/Tag RF",
|
||||||
rfCardTagSoon: "Kartë/etiketë RF (së shpejti)",
|
rfCardTagSoon: "Kartë/Tag RF (së shpejti)",
|
||||||
rfPlaceholder: "numri i kartës (ose lexo kartën)",
|
rfPlaceholder: "numri i kartës (ose lexo kartën)",
|
||||||
readCard: "Lexo kartën",
|
readCard: "Lexo kartën",
|
||||||
captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:",
|
captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:",
|
||||||
@@ -242,8 +320,17 @@ export const sq = {
|
|||||||
newPassword: "fjalëkalim i ri",
|
newPassword: "fjalëkalim i ri",
|
||||||
role: "Roli",
|
role: "Roli",
|
||||||
resetPassword: "Rivendos fjalëkalimin",
|
resetPassword: "Rivendos fjalëkalimin",
|
||||||
|
edit: "Ndrysho",
|
||||||
|
editTitle: "Ndrysho përdoruesin",
|
||||||
|
save: "Ruaj",
|
||||||
delete: "Fshi",
|
delete: "Fshi",
|
||||||
confirmDelete: "Të fshihet përdoruesi \"{{name}}\"?",
|
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: {
|
roles: {
|
||||||
title: "Rolet",
|
title: "Rolet",
|
||||||
@@ -306,6 +393,31 @@ export const sq = {
|
|||||||
openNow: "Hap turnin tani",
|
openNow: "Hap turnin tani",
|
||||||
opening: "Duke hapur…",
|
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: {
|
pay: {
|
||||||
ticket: "Bileta",
|
ticket: "Bileta",
|
||||||
entry: "Hyrja",
|
entry: "Hyrja",
|
||||||
|
|||||||
@@ -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 <html>. 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 <html>. 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");
|
||||||
|
}
|
||||||
+167
-21
@@ -9,10 +9,11 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
import { can, closeShift, logout, openShift, setLanguagePref } from "./api.js";
|
import { can, closeShift, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
|
||||||
import { qk, queryClient } from "./lib/query.js";
|
import { qk, queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
|
import { applyTheme } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||||
@@ -25,6 +26,7 @@ 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";
|
||||||
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
|
|
||||||
// Code-based TanStack Router (no file-based codegen — the app is small enough that
|
// 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
|
// 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 (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
activeOptions={{ exact }}
|
||||||
|
className="border-b-2 border-transparent px-3 py-2 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||||
|
* tab's screen via <Outlet>. 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 (
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<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("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
||||||
|
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
|
||||||
|
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||||
|
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||||
|
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||||
|
{show("shift:read") && <SetupTab to="/setup/shifts" label={t("nav.shifts")} />}
|
||||||
|
</nav>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
|
/** 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. */
|
* and applies it immediately. Updates the router-context user so App re-syncs. */
|
||||||
function LanguageToggle({
|
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 (
|
||||||
|
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
||||||
|
{(["dark", "light"] as const).map((th) => (
|
||||||
|
<button
|
||||||
|
key={th}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pick(th)}
|
||||||
|
className={`rounded-term px-1.5 py-0.5 ${
|
||||||
|
user.theme === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(th === "dark" ? "common.themeDark" : "common.themeLight")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
* Header shift control — the site-wide single-open shift expressed as one button:
|
||||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||||
@@ -167,23 +245,28 @@ function RootLayout() {
|
|||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<NavLink to="/booth" label={t("nav.booth")} />
|
<NavLink to="/booth" label={t("nav.booth")} />
|
||||||
<NavLink to="/shift" label={t("nav.shift")} />
|
<NavLink to="/shift" label={t("nav.shift")} />
|
||||||
{show("site:update") && <NavLink to="/setup" label={t("nav.setup")} />}
|
{/* One Setup entry — its tabs hold devices/tariff/subscriptions/site/users/
|
||||||
{show("tariff:read") && <NavLink to="/tariff" label={t("nav.tariff")} />}
|
roles/shifts. Shown if the user can reach ANY of those screens (an
|
||||||
{show("subscription:read") && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
|
operator with only shift:read still gets in, landing on Shifts). */}
|
||||||
{show("site:read") && <NavLink to="/site" label={t("nav.site")} />}
|
{(show("site:update") ||
|
||||||
{show("user:read") && <NavLink to="/users" label={t("nav.users")} />}
|
show("tariff:read") ||
|
||||||
{show("role:read") && <NavLink to="/roles" label={t("nav.roles")} />}
|
show("subscription:read") ||
|
||||||
|
show("site:read") ||
|
||||||
|
show("user:read") ||
|
||||||
|
show("role:read") ||
|
||||||
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{user && <ShiftButton />}
|
{user && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
<StatusDot />
|
||||||
<span className="text-[11px] text-term-muted">
|
<span className="text-[11px] text-term-muted">
|
||||||
{user?.username} · {user?.roleName}
|
{user?.username} · {user?.roleName}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-term border border-term-border px-2 py-0.5 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text"
|
className="btn btn-ghost btn-sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await logout();
|
await logout();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -216,6 +299,26 @@ const boothRoute = createRoute({
|
|||||||
component: BoothScreen,
|
component: BoothScreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Back-compat: the config screens used to be top-level routes. They now live under
|
||||||
|
// /setup as tabs — redirect the old paths so existing bookmarks/links don't 404.
|
||||||
|
const legacyRedirects = (
|
||||||
|
[
|
||||||
|
["/tariff", "/setup/tariff"],
|
||||||
|
["/subscriptions", "/setup/subscriptions"],
|
||||||
|
["/site", "/setup/site"],
|
||||||
|
["/users", "/setup/users"],
|
||||||
|
["/roles", "/setup/roles"],
|
||||||
|
] as const
|
||||||
|
).map(([from, to]) =>
|
||||||
|
createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: from,
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const shiftRoute = createRoute({
|
const shiftRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/shift",
|
path: "/shift",
|
||||||
@@ -235,27 +338,55 @@ function requirePerm(perm: Permission) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Setup tabs in display order, each with the permission its screen needs. Used
|
||||||
|
// to land a user on the FIRST tab they may see when they open /setup without
|
||||||
|
// `site:update` (e.g. an operator who only has shift:read → goes to /setup/shifts).
|
||||||
|
const SETUP_TABS: { to: string; perm: Permission }[] = [
|
||||||
|
{ to: "/setup", perm: "site:update" },
|
||||||
|
{ to: "/setup/tariff", perm: "tariff:read" },
|
||||||
|
{ to: "/setup/subscriptions", perm: "subscription:read" },
|
||||||
|
{ to: "/setup/site", perm: "site:read" },
|
||||||
|
{ to: "/setup/users", perm: "user:read" },
|
||||||
|
{ to: "/setup/roles", perm: "role:read" },
|
||||||
|
{ to: "/setup/shifts", perm: "shift:read" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// /setup is a LAYOUT route (tab bar + <Outlet>); the config screens are its
|
||||||
|
// children. The layout itself has no permission gate — each child enforces its own
|
||||||
|
// (so a user who can reach ANY tab gets the hub, but only the tabs they're allowed).
|
||||||
const setupRoute = createRoute({
|
const setupRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/setup",
|
path: "/setup",
|
||||||
beforeLoad: ({ context }) => requirePerm("site:update")(context),
|
component: SetupLayout,
|
||||||
|
});
|
||||||
|
// Index tab = Devices (the former SetupWizard). Lives at /setup exactly. A user who
|
||||||
|
// lacks site:update (e.g. an operator) is redirected to the FIRST tab they CAN see
|
||||||
|
// rather than bounced to the booth — so "Setup" always lands somewhere useful.
|
||||||
|
const setupDevicesRoute = createRoute({
|
||||||
|
getParentRoute: () => setupRoute,
|
||||||
|
path: "/",
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (can(context.user, "site:update")) return;
|
||||||
|
const firstOther = SETUP_TABS.find((tab) => tab.to !== "/setup" && can(context.user, tab.perm));
|
||||||
|
throw redirect({ to: firstOther?.to ?? "/booth" });
|
||||||
|
},
|
||||||
component: () => <SetupWizard />,
|
component: () => <SetupWizard />,
|
||||||
});
|
});
|
||||||
const tariffRoute = createRoute({
|
const tariffRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "/tariff",
|
path: "tariff",
|
||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||||
component: () => <TariffComposer />,
|
component: () => <TariffComposer />,
|
||||||
});
|
});
|
||||||
const subscriptionsRoute = createRoute({
|
const subscriptionsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "/subscriptions",
|
path: "subscriptions",
|
||||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
||||||
component: () => <SubscriptionManager />,
|
component: () => <SubscriptionManager />,
|
||||||
});
|
});
|
||||||
const siteRoute = createRoute({
|
const siteRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "/site",
|
path: "site",
|
||||||
beforeLoad: ({ context }) => requirePerm("site:read")(context),
|
beforeLoad: ({ context }) => requirePerm("site:read")(context),
|
||||||
component: function SiteRoute() {
|
component: function SiteRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
@@ -263,8 +394,8 @@ const siteRoute = createRoute({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const usersRoute = createRoute({
|
const usersRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "/users",
|
path: "users",
|
||||||
beforeLoad: ({ context }) => requirePerm("user:read")(context),
|
beforeLoad: ({ context }) => requirePerm("user:read")(context),
|
||||||
component: function UsersRoute() {
|
component: function UsersRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
@@ -272,25 +403,40 @@ const usersRoute = createRoute({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const rolesRoute = createRoute({
|
const rolesRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "/roles",
|
path: "roles",
|
||||||
beforeLoad: ({ context }) => requirePerm("role:read")(context),
|
beforeLoad: ({ context }) => requirePerm("role:read")(context),
|
||||||
component: function RolesRoute() {
|
component: function RolesRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
return <RolesManager user={user} />;
|
return <RolesManager user={user} />;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Shift history. Gated by shift:read (operators have it) — the SERVER scopes the
|
||||||
|
// data: operators see only their own; shift:cash holders see all + can filter.
|
||||||
|
const shiftsHistoryRoute = createRoute({
|
||||||
|
getParentRoute: () => setupRoute,
|
||||||
|
path: "shifts",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("shift:read")(context),
|
||||||
|
component: function ShiftsHistoryRoute() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
return <ShiftsHistory user={user} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
boothRoute,
|
boothRoute,
|
||||||
|
...legacyRedirects,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
setupRoute,
|
setupRoute.addChildren([
|
||||||
|
setupDevicesRoute,
|
||||||
tariffRoute,
|
tariffRoute,
|
||||||
subscriptionsRoute,
|
subscriptionsRoute,
|
||||||
siteRoute,
|
siteRoute,
|
||||||
usersRoute,
|
usersRoute,
|
||||||
rolesRoute,
|
rolesRoute,
|
||||||
|
shiftsHistoryRoute,
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE `users` ADD `theme` text DEFAULT 'dark' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `full_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `phone` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `email` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `address` text;
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
"when": 1781885000000,
|
"when": 1781885000000,
|
||||||
"tag": "0007_rbac",
|
"tag": "0007_rbac",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885100000,
|
||||||
|
"tag": "0008_user_profile_theme",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,19 @@ export const users = sqliteTable("users", {
|
|||||||
language: text("language", { enum: ["sq", "en"] })
|
language: text("language", { enum: ["sq", "en"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("sq"),
|
.default("sq"),
|
||||||
|
// Preferred UI theme for this user. Persisted like `language` (read on login,
|
||||||
|
// restored from any booth, changed without a token refresh). Dark is the default
|
||||||
|
// (the booth runs in a dark room). Printed tickets are unaffected. See i18n.md.
|
||||||
|
theme: text("theme", { enum: ["dark", "light"] })
|
||||||
|
.notNull()
|
||||||
|
.default("dark"),
|
||||||
|
// Optional operator profile metadata — display name + contact details. All
|
||||||
|
// nullable; only username/password/role are required to create a user. fullName
|
||||||
|
// (when set) is the human label for audit/Z-report display.
|
||||||
|
fullName: text("full_name"),
|
||||||
|
phone: text("phone"),
|
||||||
|
email: text("email"),
|
||||||
|
address: text("address"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
|||||||
Reference in New Issue
Block a user