Compare commits
3 Commits
ef0ecadff9
...
040c0ff4ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 040c0ff4ca | |||
| 8444bf34c3 | |||
| 808fb26ab6 |
@@ -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<void> {
|
||||
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 { 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.
|
||||
|
||||
@@ -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<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> {
|
||||
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. */
|
||||
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<void> {
|
||||
}
|
||||
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<void> {
|
||||
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) {
|
||||
const username = req.body.username.trim();
|
||||
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 {
|
||||
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();
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={() => handleReopen(s)}
|
||||
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
|
||||
className="btn btn-pay btn-sm shrink-0"
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
|
||||
+10
-3
@@ -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) {
|
||||
|
||||
@@ -186,7 +186,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
type="button"
|
||||
onClick={handleOpenShift}
|
||||
disabled={openingShift}
|
||||
className="mt-2 rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
className="btn btn-go btn-sm mt-2"
|
||||
>
|
||||
{openingShift ? t("shift.opening") : t("shift.openNow")}
|
||||
</button>
|
||||
@@ -263,11 +263,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
key={tn}
|
||||
type="button"
|
||||
onClick={() => setTender(tn)}
|
||||
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
|
||||
tender === tn
|
||||
? "border-term-amber text-term-amber"
|
||||
: "border-term-border text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{t(`pay.${tn}`)}
|
||||
</button>
|
||||
@@ -279,6 +275,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<label className="flex items-center gap-2 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={voucher}
|
||||
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
|
||||
/>
|
||||
@@ -304,7 +301,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
type="button"
|
||||
onClick={handleReprintReceipt}
|
||||
disabled={reprinting}
|
||||
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text disabled:opacity-50"
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
|
||||
</button>
|
||||
@@ -312,7 +309,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
@@ -322,7 +319,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
@@ -333,7 +330,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
type="button"
|
||||
onClick={handleOpenBarrier}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="rounded-term border border-term-cyan bg-term-cyan/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-cyan disabled:opacity-50"
|
||||
className="btn btn-pay btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
@@ -342,7 +339,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
className="btn btn-go btn-lg"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
|
||||
@@ -111,12 +111,9 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t("booth.scanPlaceholder")}
|
||||
inputMode="numeric"
|
||||
className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
|
||||
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
|
||||
>
|
||||
<button type="submit" className="btn btn-primary btn-lg">
|
||||
{t("booth.open")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
+23
-29
@@ -23,37 +23,31 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
|
||||
<h1>{t("auth.title")}</h1>
|
||||
<form onSubmit={submit}>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
{t("auth.username")}
|
||||
<br />
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
|
||||
<form onSubmit={submit} className="card w-full max-w-sm p-6">
|
||||
<h1 className="mb-5 text-h5 font-semibold uppercase tracking-widest text-term-amber">{t("auth.title")}</h1>
|
||||
<div className="field mb-3">
|
||||
<label className="label">{t("auth.username")}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<label>
|
||||
{t("auth.password")}
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
<div className="field mb-3">
|
||||
<label className="label">{t("auth.password")}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<button type="submit" disabled={busy || !username || !password}>
|
||||
{error && <p className="mb-3 text-[12px] text-term-red">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
|
||||
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type Permission,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// Role management (admin). Compose a role from the permission grid (a checkbox
|
||||
// matrix of resource × action) and name it; users are then assigned a role. The
|
||||
@@ -56,8 +57,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("roles.title")}</h1>
|
||||
{canCreate && (
|
||||
<button type="button" onClick={() => { setEditing("new"); 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">
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => { setEditing("new"); setError(null); }}>
|
||||
{t("roles.add")}
|
||||
</button>
|
||||
)}
|
||||
@@ -65,21 +65,28 @@ export function RolesManager({ 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>}
|
||||
|
||||
{editing && (
|
||||
<RoleEditor
|
||||
role={editing === "new" ? null : editing}
|
||||
grouped={grouped}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
if (editing === "new") await createRole(v);
|
||||
else await updateRole(editing.id, v);
|
||||
setEditing(null);
|
||||
invalidate();
|
||||
} catch (e) { onError(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
open={editing != null}
|
||||
onClose={() => setEditing(null)}
|
||||
title={editing && editing !== "new" ? t("roles.editTitle") : t("roles.new")}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
{editing && (
|
||||
<RoleEditor
|
||||
role={editing === "new" ? null : editing}
|
||||
grouped={grouped}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
if (editing === "new") await createRole(v);
|
||||
else await updateRole(editing.id, v);
|
||||
setEditing(null);
|
||||
invalidate();
|
||||
} catch (e) { onError(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{roles.map((r) => (
|
||||
@@ -98,13 +105,11 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canUpdate && !r.builtin && (
|
||||
<button type="button" onClick={() => { setEditing(r); setError(null); }}
|
||||
className="text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">{t("roles.edit")}</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
|
||||
)}
|
||||
{canDelete && !r.builtin && (
|
||||
<button type="button"
|
||||
onClick={() => { if (confirm(t("roles.confirmDelete", { name: r.name }))) deleteRoleSafe(r.id, invalidate, onError); }}
|
||||
className="text-[11px] uppercase tracking-wider text-term-red hover:text-term-text">{t("roles.delete")}</button>
|
||||
<button type="button" className="btn btn-danger btn-sm"
|
||||
onClick={() => { if (confirm(t("roles.confirmDelete", { name: r.name }))) deleteRoleSafe(r.id, invalidate, onError); }}>{t("roles.delete")}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,17 +145,13 @@ function RoleEditor({
|
||||
const valid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-term border border-term-border bg-term-panel p-3">
|
||||
<div className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{role ? t("roles.editTitle") : t("roles.new")}
|
||||
<div>
|
||||
<div className="field mb-3 w-64">
|
||||
<span className="label">{t("roles.name")}</span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<label className="mb-3 block text-[11px] text-term-muted">
|
||||
{t("roles.name")}
|
||||
<input value={name} onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 w-64 rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
|
||||
</label>
|
||||
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("roles.permissions")}</div>
|
||||
<div className="label">{t("roles.permissions")}</div>
|
||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||
{Object.entries(grouped).map(([resource, list]) => (
|
||||
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
||||
@@ -159,7 +160,7 @@ function RoleEditor({
|
||||
const action = p.split(":")[1]!;
|
||||
return (
|
||||
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
|
||||
<input type="checkbox" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||||
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||||
{action}
|
||||
</label>
|
||||
);
|
||||
@@ -169,10 +170,8 @@ function RoleEditor({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<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>
|
||||
<button type="button" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}
|
||||
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>
|
||||
<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={() => onSubmit({ name: name.trim(), permissions: [...perms] })}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+214
-240
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
assignDevice,
|
||||
editDevice,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
|
||||
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
|
||||
@@ -27,25 +29,30 @@ import {
|
||||
// Direction is a property of the relay, inherited by bound devices. The data model
|
||||
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
|
||||
|
||||
const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = {
|
||||
// Categories carry i18n KEYS (resolved at render via t()), not literal copy.
|
||||
// `titleKey` is the section heading; `nounKey` resolves to the singular noun used in
|
||||
// the add/edit buttons, modal titles and confirm prompts.
|
||||
const CONTROLLER: { key: DeviceCategory; titleKey: string; nounKey: string } = {
|
||||
key: "access",
|
||||
title: "Controllers (barriers + entry button)",
|
||||
noun: "controller",
|
||||
titleKey: "setup.catControllers",
|
||||
nounKey: "setup.nounController",
|
||||
};
|
||||
// Categories that BIND to a controller relay (direction inherited from the relay).
|
||||
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" },
|
||||
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" },
|
||||
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" },
|
||||
const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
|
||||
{ key: "reader", titleKey: "setup.catReaders", nounKey: "setup.nounReader" },
|
||||
{ key: "camera", titleKey: "setup.catCameras", nounKey: "setup.nounCamera" },
|
||||
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
|
||||
];
|
||||
|
||||
const DIRECTION_LABELS: Record<Direction, string> = {
|
||||
entry: "Entry",
|
||||
exit: "Exit",
|
||||
both: "Both (entry + exit)",
|
||||
// Translated direction label (relay direction / inherited binding).
|
||||
const DIRECTION_KEYS: Record<Direction, string> = {
|
||||
entry: "setup.dirEntry",
|
||||
exit: "setup.dirExit",
|
||||
both: "setup.dirBoth",
|
||||
};
|
||||
|
||||
export function SetupWizard() {
|
||||
const { t } = useTranslation();
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -61,25 +68,21 @@ export function SetupWizard() {
|
||||
reloadState();
|
||||
}, [reloadState]);
|
||||
|
||||
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
||||
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
||||
if (error) return <p className="px-4 py-6 text-term-red">{t("setup.failedToLoad", { error })}</p>;
|
||||
if (!catalog || !assignments) return <p className="px-4 py-6 text-term-muted">{t("setup.loadingCatalog")}</p>;
|
||||
|
||||
// Controllers are needed before binding readers/cameras (they pick a controller relay).
|
||||
const controllers = assignments.filter((a) => a.category === "access");
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>First-run setup</h2>
|
||||
<p style={{ color: "#666", fontSize: "0.9em" }}>
|
||||
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.
|
||||
</p>
|
||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
||||
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
|
||||
|
||||
<CategorySection
|
||||
category={CONTROLLER.key}
|
||||
title={CONTROLLER.title}
|
||||
noun={CONTROLLER.noun}
|
||||
title={t(CONTROLLER.titleKey)}
|
||||
noun={t(CONTROLLER.nounKey)}
|
||||
entries={catalog[CONTROLLER.key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
pushCapableIds={catalog.pushCapable}
|
||||
@@ -88,12 +91,12 @@ export function SetupWizard() {
|
||||
onChanged={reloadState}
|
||||
/>
|
||||
|
||||
{BOUND.map(({ key, title, noun }) => (
|
||||
{BOUND.map(({ key, titleKey, nounKey }) => (
|
||||
<CategorySection
|
||||
key={key}
|
||||
category={key}
|
||||
title={title}
|
||||
noun={noun}
|
||||
title={t(titleKey)}
|
||||
noun={t(nounKey)}
|
||||
entries={catalog[key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
pushCapableIds={catalog.pushCapable}
|
||||
@@ -127,101 +130,84 @@ function CategorySection({
|
||||
assignments: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
// The form is popped out in a Modal. `formFor` selects what it edits:
|
||||
// - "new" → the add form
|
||||
// - an Assignment → edit that device in place
|
||||
// - null → closed.
|
||||
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined;
|
||||
// Show the add form for an empty category or an explicit "+ Add", but not while
|
||||
// editing an existing row (that row renders its own inline form).
|
||||
const showForm = !editing && (adding || assignments.length === 0);
|
||||
|
||||
// Binding categories need a controller to point at first.
|
||||
const isBound = category !== "access";
|
||||
const blockedNoController = isBound && controllers.length === 0;
|
||||
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
||||
|
||||
return (
|
||||
<fieldset style={{ marginTop: "1rem" }}>
|
||||
<legend>{title}</legend>
|
||||
<fieldset className="card mt-4 p-4">
|
||||
<legend className="px-1 text-h6 font-semibold uppercase tracking-wider text-term-text">{title}</legend>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
margin: "0 0 0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "#fef3c7",
|
||||
border: "1px solid #f59e0b",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "#92400e" }}>⚠ Saved, but action needed:</strong>
|
||||
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.25rem", color: "#92400e" }}>
|
||||
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
|
||||
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={() => setWarnings([])} style={{ marginTop: "0.5rem" }}>
|
||||
Dismiss
|
||||
<button type="button" className="btn btn-sm mt-2" onClick={() => setWarnings([])}>
|
||||
{t("setup.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignments.length > 0 && (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||
{assignments.map((a) =>
|
||||
editingId === a.id ? (
|
||||
<li key={a.id} style={{ listStyle: "none", padding: 0 }}>
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
editing={a}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</li>
|
||||
) : (
|
||||
<AssignmentRow
|
||||
key={a.id}
|
||||
assignment={a}
|
||||
controllers={controllers}
|
||||
onChanged={onChanged}
|
||||
onEdit={() => {
|
||||
setAdding(false);
|
||||
setEditingId(a.id);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<ul className="mb-3 list-none p-0">
|
||||
{assignments.map((a) => (
|
||||
<AssignmentRow
|
||||
key={a.id}
|
||||
assignment={a}
|
||||
controllers={controllers}
|
||||
onChanged={onChanged}
|
||||
onEdit={() => setFormFor(a)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{blockedNoController ? (
|
||||
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||
) : editing ? null : showForm ? (
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
setAdding(false);
|
||||
}}
|
||||
onCancel={assignments.length > 0 ? () => setAdding(false) : undefined}
|
||||
/>
|
||||
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||
) : (
|
||||
<button type="button" onClick={() => setAdding(true)}>
|
||||
+ Add another {noun}
|
||||
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
|
||||
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add/edit form — popped out. One modal per category; the device list stays
|
||||
in the page behind it. */}
|
||||
<Modal
|
||||
open={formFor != null}
|
||||
onClose={() => setFormFor(null)}
|
||||
title={editing ? t("setup.editTitle", { noun }) : t("setup.addTitle", { noun })}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
{formFor != null && (
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
editing={editing}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
setFormFor(null);
|
||||
}}
|
||||
onCancel={() => setFormFor(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -237,6 +223,7 @@ function AssignmentRow({
|
||||
onChanged: () => Promise<void> | void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -244,7 +231,7 @@ function AssignmentRow({
|
||||
const host = typeof cfg.host === "string" ? cfg.host : null;
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Remove this ${assignment.driverId} device?`)) return;
|
||||
if (!confirm(t("setup.confirmRemove", { driver: assignment.driverId }))) return;
|
||||
setRemoving(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -257,26 +244,18 @@ function AssignmentRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.4rem 0.5rem",
|
||||
borderBottom: "1px solid #eee",
|
||||
}}
|
||||
>
|
||||
<strong>{assignment.driverId}</strong>
|
||||
{host && <span style={{ color: "#666" }}>{host}</span>}
|
||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
|
||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||
<button type="button" onClick={onEdit} disabled={removing}>
|
||||
Edit
|
||||
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
|
||||
<span className="flex-1" />
|
||||
{error && <span className="text-term-red">{error}</span>}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onEdit} disabled={removing}>
|
||||
{t("setup.edit")}
|
||||
</button>
|
||||
<button type="button" onClick={remove} disabled={removing}>
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={remove} disabled={removing}>
|
||||
{removing ? t("setup.removing") : t("setup.remove")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
@@ -284,12 +263,13 @@ function AssignmentRow({
|
||||
|
||||
/** Inline summary of an assignment's direction/binding for the list. */
|
||||
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
||||
const { t } = useTranslation();
|
||||
const cfg = assignment.config as Record<string, unknown>;
|
||||
if (assignment.category === "access") {
|
||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>;
|
||||
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
||||
return (
|
||||
<span style={{ display: "flex", gap: "0.35rem" }}>
|
||||
<span className="flex gap-1.5">
|
||||
{relays.map((r) => (
|
||||
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
||||
))}
|
||||
@@ -299,7 +279,7 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
||||
// Bound device: show controller + relay it points at, with inherited direction.
|
||||
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||
if (!controllerId || relay == null) return <em style={{ color: "#b45309" }}>unbound</em>;
|
||||
if (!controllerId || relay == null) return <em className="text-term-amber">{t("setup.unbound")}</em>;
|
||||
const controller = controllers.find((c) => c.id === controllerId);
|
||||
const spec = controller
|
||||
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
|
||||
@@ -333,6 +313,7 @@ function DeviceForm({
|
||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// On edit the driver is fixed (you can't change what KIND of device a slot is —
|
||||
// that's a remove + re-add); pre-select it and lock the picker.
|
||||
const editCfg = editing?.config as Record<string, unknown> | undefined;
|
||||
@@ -500,15 +481,15 @@ function DeviceForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}>
|
||||
<div>
|
||||
{entries.length === 0 ? (
|
||||
<em>No drivers registered.</em>
|
||||
<em className="text-term-muted">{t("setup.noDrivers")}</em>
|
||||
) : (
|
||||
// Driver is locked when editing — changing the kind of device is a
|
||||
// remove + re-add, not an in-place edit.
|
||||
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
|
||||
<select className="select w-auto min-w-64" value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
|
||||
<option value="" disabled>
|
||||
Choose a device…
|
||||
{t("setup.chooseDevice")}
|
||||
</option>
|
||||
{entries.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
@@ -519,26 +500,26 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p>
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
|
||||
|
||||
{canDiscover && (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<button type="button" onClick={scan} disabled={scanning}>
|
||||
{scanning ? "Scanning…" : "Scan for controllers"}
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
|
||||
{scanning ? t("setup.scanning") : t("setup.scan")}
|
||||
</button>
|
||||
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
||||
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
||||
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
|
||||
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||
{found && found.length > 0 && (
|
||||
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1rem" }}>
|
||||
<ul className="mt-2 list-none p-0">
|
||||
{found.map((d) => (
|
||||
<li key={d.id} style={{ margin: "0.25rem 0" }}>
|
||||
<button type="button" onClick={() => applyDiscovered(d)}>
|
||||
Use
|
||||
</button>{" "}
|
||||
<strong>{d.label}</strong>{" "}
|
||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
|
||||
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
|
||||
{t("setup.use")}
|
||||
</button>
|
||||
<strong className="text-term-text">{d.label}</strong>
|
||||
<HealthBadge status={d.health.status} />
|
||||
{d.info?.firmware && <span style={{ color: "#666" }}> · fw {d.info.firmware}</span>}
|
||||
{d.info?.firmware && <span className="text-term-muted"> · fw {d.info.firmware}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -547,38 +528,40 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{selected.configFields.map((f) => (
|
||||
<div key={f.key} style={{ margin: "0.25rem 0" }}>
|
||||
<label>
|
||||
<div key={f.key} className="field my-2 max-w-sm">
|
||||
<label className="label">
|
||||
{f.label}
|
||||
{f.required ? " *" : ""}{" "}
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
>
|
||||
{f.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
||||
placeholder={f.help}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{f.required ? " *" : ""}
|
||||
</label>
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
className="select"
|
||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
>
|
||||
{f.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
||||
placeholder={f.help}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -600,34 +583,34 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<button type="button" onClick={test} disabled={testing}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
||||
{testing ? t("setup.testing") : t("setup.test")}
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
|
||||
{saving ? t("setup.saving") : editing ? t("setup.saveChanges") : t("setup.saveConfigure")}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onCancel} disabled={saving}>
|
||||
{t("setup.cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
|
||||
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||
{tested && (
|
||||
<div style={{ margin: "0.5rem 0 0" }}>
|
||||
<div>
|
||||
Device: <HealthBadge status={tested.health.status} />
|
||||
{tested.health.detail && <span style={{ color: "#666" }}> — {tested.health.detail}</span>}
|
||||
<div className="mt-2 text-[12px]">
|
||||
<div className="text-term-text">
|
||||
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
|
||||
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
|
||||
</div>
|
||||
{tested.preconditions.ok ? (
|
||||
<div style={{ color: "#16a34a" }}>● preconditions OK</div>
|
||||
<div className="text-term-green">{t("setup.preconditionsOk")}</div>
|
||||
) : (
|
||||
tested.preconditions.issues.map((i) => (
|
||||
<div key={i.key} style={{ color: "#d97706" }}>
|
||||
<div key={i.key} className="text-term-amber">
|
||||
⚠ {i.message}
|
||||
{i.fixable && <span style={{ color: "#666" }}> (auto-fixed on save)</span>}
|
||||
{i.fixable && <span className="text-term-muted"> {t("setup.autoFixedOnSave")}</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -635,33 +618,29 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{backendIps && backendIps.length > 0 && (
|
||||
<div style={{ margin: "0.5rem 0 0" }}>
|
||||
<label>
|
||||
Backend push IP{" "}
|
||||
<select value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
|
||||
<div className="mt-3">
|
||||
<div className="field max-w-md">
|
||||
<label className="label">{t("setup.backendPushIp")}</label>
|
||||
<select className="select" value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
|
||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||
<option value="" disabled>
|
||||
Choose an address…
|
||||
{t("setup.chooseAddress")}
|
||||
</option>
|
||||
)}
|
||||
{backendIps.map((c) => (
|
||||
<option key={c.ip} value={c.ip}>
|
||||
{c.ip} ({c.iface}){c.onDeviceSubnet ? " — on device subnet" : ""}
|
||||
{c.ip} ({c.iface}){c.onDeviceSubnet ? ` ${t("setup.onDeviceSubnet")}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||
<span style={{ marginLeft: 8, color: "#d97706" }}>
|
||||
⚠ no NIC on the device's subnet — the device may not reach the backend
|
||||
</span>
|
||||
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||
)}
|
||||
<p style={{ margin: "0.25rem 0 0", color: "#666", fontSize: "0.85em" }}>
|
||||
The address this device will POST input events to.
|
||||
</p>
|
||||
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
|
||||
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -671,6 +650,7 @@ function DeviceForm({
|
||||
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
||||
* the input terminal its entry button is wired to. */
|
||||
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
||||
const { t } = useTranslation();
|
||||
function update(i: number, patch: Partial<RelaySpec>) {
|
||||
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
}
|
||||
@@ -683,53 +663,50 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong>
|
||||
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}>
|
||||
Each relay opens one barrier. Set its direction; for transient entry, set which input
|
||||
terminal the entry button is wired to.
|
||||
</p>
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
|
||||
{relays.map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}>
|
||||
<label>
|
||||
Relay{" "}
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.relay}
|
||||
style={{ width: "3.5rem" }}
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{DIRECTION_LABELS[d]}
|
||||
{t(DIRECTION_KEYS[d])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(r.direction === "entry" || r.direction === "both") && (
|
||||
<label>
|
||||
Entry button on terminal{" "}
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.entryButtonTerminal")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.button ?? ""}
|
||||
placeholder="—"
|
||||
style={{ width: "3.5rem" }}
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{relays.length > 1 && (
|
||||
<button type="button" onClick={() => remove(i)}>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}>
|
||||
+ Add relay
|
||||
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
||||
{t("setup.addRelay")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -750,6 +727,7 @@ function BindingPicker({
|
||||
onControllerChange: (id: string) => void;
|
||||
onRelayChange: (relay: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const controller = controllers.find((c) => c.id === controllerId);
|
||||
const relays: RelaySpec[] = controller
|
||||
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
|
||||
@@ -757,14 +735,14 @@ function BindingPicker({
|
||||
const chosen = relays.find((r) => r.relay === relay);
|
||||
|
||||
return (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong>
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}>
|
||||
<label>
|
||||
Controller{" "}
|
||||
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.controller")}
|
||||
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
Choose…
|
||||
{t("setup.choose")}
|
||||
</option>
|
||||
{controllers.map((c) => {
|
||||
const host = (c.config as Record<string, unknown>).host;
|
||||
@@ -777,53 +755,49 @@ function BindingPicker({
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Relay{" "}
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={relay === "" ? "" : String(relay)}
|
||||
disabled={!controller}
|
||||
onChange={(e) => onRelayChange(Number(e.target.value))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Choose…
|
||||
{t("setup.choose")}
|
||||
</option>
|
||||
{relays.map((r) => (
|
||||
<option key={r.relay} value={r.relay}>
|
||||
Relay {r.relay} ({DIRECTION_LABELS[r.direction]})
|
||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />}
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
||||
</div>
|
||||
{controller && relays.length === 0 && (
|
||||
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}>
|
||||
This controller has no relays configured.
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
||||
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280";
|
||||
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
|
||||
const cls =
|
||||
direction === "entry"
|
||||
? "border-term-green text-term-green"
|
||||
: direction === "exit"
|
||||
? "border-term-amber text-term-amber"
|
||||
: "border-term-muted text-term-muted";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
color,
|
||||
border: `1px solid ${color}`,
|
||||
borderRadius: 4,
|
||||
padding: "0 0.35rem",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
{label ?? direction}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthBadge({ status }: { status: string }) {
|
||||
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
||||
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
||||
const cls = status === "ready" ? "text-term-green" : status === "degraded" ? "text-term-amber" : "text-term-red";
|
||||
return <span className={`font-semibold ${cls}`}>● {status}</span>;
|
||||
}
|
||||
|
||||
@@ -85,76 +85,78 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||
<strong>{t("shift.label")}</strong>{" "}
|
||||
{startedAt ? (
|
||||
<>
|
||||
<span style={{ color: "#16a34a" }}>{t("shift.open")}</span> {t("shift.since")}{" "}
|
||||
{new Date(startedAt).toLocaleString()}{" "}
|
||||
<button type="button" onClick={end} disabled={busy}>
|
||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
|
||||
<button type="button" onClick={start} disabled={busy}>
|
||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<section className="card mt-6 max-w-md p-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[13px]">
|
||||
<strong className="uppercase tracking-wider text-term-muted">{t("shift.label")}</strong>
|
||||
{startedAt ? (
|
||||
<>
|
||||
<span className="font-semibold text-term-green">{t("shift.open")}</span>
|
||||
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
|
||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-term-muted">{t("shift.notStarted")}</span>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Live drawer balance (what's in the till right now / inherited). */}
|
||||
{drawerMinor != null && (
|
||||
<div style={{ marginTop: "0.5rem", color: "#555" }}>
|
||||
{t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
|
||||
{startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
|
||||
<div className="mt-2 text-[12px] text-term-text">
|
||||
{t("shift.drawer")} <strong className="tabular-nums">{money(drawerMinor, currency)}</strong>
|
||||
{startedAt && <span className="text-term-muted"> {t("shift.openingFloatInherited")}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
||||
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
||||
|
||||
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
|
||||
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
|
||||
<div className="mt-4 border-t border-term-border pt-3">
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{t("shift.drawerCashAdmin")}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-28"
|
||||
value={moveAmount}
|
||||
onChange={(e) => setMoveAmount(e.target.value)}
|
||||
placeholder={t("shift.amount")}
|
||||
inputMode="decimal"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<input
|
||||
className="input min-w-36 flex-1"
|
||||
value={moveReason}
|
||||
onChange={(e) => setMoveReason(e.target.value)}
|
||||
placeholder={t("shift.reasonPlaceholder")}
|
||||
style={{ flex: 1, minWidth: 140 }}
|
||||
/>
|
||||
<button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
|
||||
<button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => move(1)}>{t("shift.load")}</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
||||
</div>
|
||||
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
|
||||
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||
<div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
|
||||
<div>{t("shift.payments")} {report.paymentCount}</div>
|
||||
<div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
||||
<div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
|
||||
<div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
|
||||
<div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
|
||||
<div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
|
||||
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
||||
<div className="text-term-text">{t("shift.payments")} {report.paymentCount}</div>
|
||||
<div className="text-term-text">{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
||||
<div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
|
||||
<div className="text-term-text">{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
|
||||
<div className="text-term-text">{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
|
||||
<div className="font-semibold text-term-text">
|
||||
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
|
||||
</div>
|
||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
|
||||
<div className={report.printed ? "mt-1 text-term-green" : "mt-1 text-term-amber"}>
|
||||
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -62,44 +62,50 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||
<strong>{t("site.occupancy")}</strong>{" "}
|
||||
{occ == null ? (
|
||||
"…"
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600 }}>{occ.count}</span>
|
||||
{occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`}
|
||||
{occ.capacity != null && (
|
||||
<span style={{ color: "#666" }}> · {occ.free} {t("site.free")}</span>
|
||||
)}
|
||||
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>{t("site.full")}</span>}{" "}
|
||||
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
|
||||
</>
|
||||
)}
|
||||
<section className="card mt-6 max-w-md p-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[13px]">
|
||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||
{occ == null ? (
|
||||
<span className="text-term-muted">…</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-h5 font-semibold tabular-nums text-term-text">{occ.count}</span>
|
||||
<span className="tabular-nums text-term-muted">
|
||||
{occ.capacity != null ? `/ ${occ.capacity}` : t("site.noCapacitySet")}
|
||||
</span>
|
||||
{occ.capacity != null && (
|
||||
<span className="tabular-nums text-term-muted">· {occ.free} {t("site.free")}</span>
|
||||
)}
|
||||
{occ.full && <span className="font-semibold text-term-red">{t("site.full")}</span>}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={reload}>↻</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
|
||||
<label>
|
||||
{t("site.capacityLabel")}{" "}
|
||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("site.capacityLabel")}</span>
|
||||
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={exitVoucherDefault}
|
||||
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
||||
/>
|
||||
{t("site.printExitDefault")}
|
||||
<span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
|
||||
<span className="hint">{t("site.printExitHint")}</span>
|
||||
</label>
|
||||
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
|
||||
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{t("site.parkDetails")}
|
||||
</div>
|
||||
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
|
||||
{t(labelKey)}
|
||||
<div key={key} className="field">
|
||||
<span className="label">{t(labelKey)}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
rows={2}
|
||||
@@ -107,16 +113,17 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
placeholder={phKey ? t(phKey) : undefined}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<button type="button" onClick={save}>{t("site.save")}</button>
|
||||
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
||||
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type SubscriptionCredential,
|
||||
type SubscriptionInput,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
|
||||
@@ -285,89 +286,92 @@ export function SubscriptionManager() {
|
||||
if (!subs) return null;
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>{t("subs.title")}</h2>
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
||||
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
||||
<ul className="mb-3 list-none p-0">
|
||||
{subs.map((s) => (
|
||||
<li key={s.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
||||
<strong>{s.holderName ?? t("subs.unnamed")}</strong>
|
||||
<span style={{ color: s.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[s.status])}</span>
|
||||
<span style={{ color: "#0a7", fontVariantNumeric: "tabular-nums" }}>{priceLabel(s, t)}</span>
|
||||
<span style={{ color: "#666" }}>
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
|
||||
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
|
||||
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
|
||||
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
|
||||
<span className="text-term-muted">
|
||||
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
|
||||
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="flex-1" />
|
||||
{/* Print code — only when the subscription has a QR credential to encode. */}
|
||||
{s.credentials.some((c) => c.kind === "qr") && (
|
||||
<button type="button" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
|
||||
)}
|
||||
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
|
||||
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
|
||||
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
|
||||
{s.status !== "revoked" && <button type="button" className="btn btn-ghost btn-sm" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
|
||||
</li>
|
||||
))}
|
||||
{subs.length === 0 && <li style={{ color: "#777" }}>{t("subs.noneYet")}</li>}
|
||||
{subs.length === 0 && <li className="py-2 text-term-muted">{t("subs.noneYet")}</li>}
|
||||
</ul>
|
||||
|
||||
{editing == null ? (
|
||||
<button type="button" onClick={startNew}>{t("subs.add")}</button>
|
||||
) : (
|
||||
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||||
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("subs.new") : t("subs.editTitle")}</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||||
<label>{t("subs.holderName")}</label>
|
||||
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||
<label>{t("subs.contact")}</label>
|
||||
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||
<label>{t("subs.monthlyPrice")}</label>
|
||||
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={startNew}>{t("subs.add")}</button>
|
||||
|
||||
<Modal
|
||||
open={editing != null}
|
||||
onClose={() => setEditing(null)}
|
||||
title={editing === "new" ? t("subs.new") : t("subs.editTitle")}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("subs.holderName")}</label>
|
||||
<input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||
<label className="label">{t("subs.contact")}</label>
|
||||
<input className="input" value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||
<label className="label">{t("subs.monthlyPrice")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
className="input w-28"
|
||||
value={form.priceMajor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
|
||||
inputMode="decimal"
|
||||
placeholder={t("subs.pricePlaceholder")}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
<input value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} />
|
||||
<span style={{ color: "#888" }}>/ {t("subs.perMonth")}</span>
|
||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} />
|
||||
<span className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
|
||||
</span>
|
||||
<label>{t("subs.carLimit")}</label>
|
||||
<span>
|
||||
<label style={{ marginRight: "0.5rem" }}>
|
||||
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||||
<label className="label">{t("subs.carLimit")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||||
</label>
|
||||
{form.carBound && (
|
||||
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||||
<input className="input w-16" value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} />
|
||||
)}
|
||||
</span>
|
||||
<label>{t("subs.validFrom")}</label>
|
||||
<input type="date" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
||||
<label>{t("subs.months")}</label>
|
||||
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<label className="label">{t("subs.validFrom")}</label>
|
||||
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
||||
<label className="label">{t("subs.months")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-16"
|
||||
value={form.months}
|
||||
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
placeholder="1"
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
<span style={{ color: "#888" }}>{t("subs.monthsHint")}</span>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.monthsHint")}</span>
|
||||
{/* Live preview of the coverage end + the N×price total. */}
|
||||
{coverageHint && <span style={{ color: "#0a7" }}>{coverageHint}</span>}
|
||||
{coverageHint && <span className="text-[12px] text-term-cyan">{coverageHint}</span>}
|
||||
</span>
|
||||
<label>{t("subs.validToOverride")}</label>
|
||||
<input type="date" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
||||
<label>{t("subs.boundPlates")}</label>
|
||||
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||
<label className="label">{t("subs.validToOverride")}</label>
|
||||
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
||||
<label className="label">{t("subs.boundPlates")}</label>
|
||||
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||
</div>
|
||||
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
|
||||
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||
<div key={i} className="mb-1.5 flex items-center gap-2">
|
||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||
(read off a card via "Read card"). */}
|
||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<select className="select input-sm w-auto" value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<option value="qr">{t("subs.qr")}</option>
|
||||
<option value="rf">{t("subs.rfCardTag")}</option>
|
||||
</select>
|
||||
@@ -375,60 +379,57 @@ export function SubscriptionManager() {
|
||||
// QR codes are server-generated. Blank → "will be generated"; an
|
||||
// existing code is shown read-only (it can be printed; never typed).
|
||||
c.value.trim() ? (
|
||||
<input value={c.value} readOnly style={{ flex: 1, fontFamily: "ui-monospace, monospace", background: "#f6f6f6" }} />
|
||||
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
|
||||
) : (
|
||||
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
|
||||
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
||||
)
|
||||
) : (
|
||||
// RFID: the value is read off a physical card (or typed). "Read card"
|
||||
// arms a chosen reader and fills the captured value.
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} />
|
||||
<input className="input input-sm flex-1" value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} />
|
||||
)}
|
||||
{c.kind === "rf" && (
|
||||
<button type="button" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
|
||||
|
||||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||
the credential. The OTHER reader keeps serving the live flow. */}
|
||||
{capture && (
|
||||
<div style={{ marginTop: "0.5rem", padding: "0.6rem 0.75rem", border: "1px solid #0a7", borderRadius: 6, background: "#f0fbf6" }}>
|
||||
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
|
||||
{capture.phase === "pick" ? (
|
||||
<>
|
||||
<div style={{ marginBottom: "0.35rem" }}>{t("subs.captureChooseReader")}</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||
{readers.length === 0 && <span style={{ color: "#a00" }}>{t("subs.captureNoReaders")}</span>}
|
||||
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{readers.length === 0 && <span className="text-term-red">{t("subs.captureNoReaders")}</span>}
|
||||
{readers.map((r) => (
|
||||
<button key={r.id} type="button" onClick={() => pickReader(r.id)}>
|
||||
<button key={r.id} type="button" className="btn btn-pay btn-sm" onClick={() => pickReader(r.id)}>
|
||||
{t(`devices.role.${r.direction}`)} ({r.driverId})
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<span>{capture.status ?? t("subs.captureWaiting")}</span>
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-term-text">{capture.status ?? t("subs.captureWaiting")}</span>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
{t("subs.needCredentialOrPlate")}
|
||||
</p>
|
||||
<p className="hint mt-3">{t("subs.needCredentialOrPlate")}</p>
|
||||
|
||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||
<button type="button" onClick={save}>{t("subs.save")}</button>
|
||||
<button type="button" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("subs.save")}</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
|
||||
</Modal>
|
||||
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,12 +284,14 @@ export function TariffComposer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>{t("tariff.title")}</h2>
|
||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
||||
{!state?.active ? (
|
||||
<p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
|
||||
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
|
||||
{t("tariff.noRateCard")}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: "#555" }}>
|
||||
<p className="mb-4 text-[12px] text-term-muted">
|
||||
{t("tariff.activeSince", {
|
||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||
count: state.versions.length,
|
||||
@@ -297,82 +299,84 @@ export function TariffComposer() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
||||
<label>{t("tariff.currency")}</label>
|
||||
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
|
||||
<label>{t("tariff.freeEntryGrace")}</label>
|
||||
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label>{t("tariff.billingIncrement")}</label>
|
||||
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label>{t("tariff.lostTicketFee")}</label>
|
||||
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label>{t("tariff.exitGrace")}</label>
|
||||
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.currency")}</label>
|
||||
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
|
||||
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.billingIncrement")}</label>
|
||||
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.lostTicketFee")}</label>
|
||||
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label className="label">{t("tariff.exitGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.defaultCard")}</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.defaultCardHint")}</p>
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||
onAddBlock={() => addBlock("base")}
|
||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||
/>
|
||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
|
||||
<div className="card card-body">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||
onAddBlock={() => addBlock("base")}
|
||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
||||
<details style={{ marginTop: "1.25rem" }} open={form.tiers.length > 0}>
|
||||
<summary style={{ cursor: "pointer", fontWeight: 600 }}>{t("tariff.tiersAdvanced")}</summary>
|
||||
<p style={{ color: "#777", margin: "0.4rem 0", fontSize: "0.9em" }}>{t("tariff.tiersHint")}</p>
|
||||
<details className="mt-6" open={form.tiers.length > 0}>
|
||||
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
||||
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
||||
{form.tiers.map((tr, i) => (
|
||||
<fieldset key={i} style={{ border: "1px solid #ddd", borderRadius: 6, padding: "0.6rem 0.8rem", marginBottom: "0.75rem" }}>
|
||||
<legend style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<fieldset key={i} className="card mb-3 p-4">
|
||||
<legend className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
className="input w-40"
|
||||
value={tr.name}
|
||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
||||
placeholder={t("tariff.tierName")}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeTier(i)}>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</legend>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.35rem 0.75rem", alignItems: "center", maxWidth: 520 }}>
|
||||
<label>{t("tariff.tierPriority")}</label>
|
||||
<input value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} style={{ width: 70 }} />
|
||||
<label>{t("tariff.tierCategory")}</label>
|
||||
<input value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} style={{ width: 140 }} />
|
||||
<label>{t("tariff.tierDays")}</label>
|
||||
<span style={{ display: "flex", gap: "0.3rem", flexWrap: "wrap" }}>
|
||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.tierPriority")}</label>
|
||||
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
|
||||
<label className="label">{t("tariff.tierCategory")}</label>
|
||||
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
|
||||
<label className="label">{t("tariff.tierDays")}</label>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<label key={d} style={{ display: "inline-flex", alignItems: "center", gap: "0.15rem", fontSize: "0.85em" }}>
|
||||
<input type="checkbox" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
{t(`tariff.dow${d}`)}
|
||||
</label>
|
||||
))}
|
||||
</span>
|
||||
<label>{t("tariff.tierHours")}</label>
|
||||
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
|
||||
<input value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" style={{ width: 70 }} />
|
||||
<span>–</span>
|
||||
<input value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" style={{ width: 70 }} />
|
||||
<label className="label">{t("tariff.tierHours")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||
<span style={{ color: "#777", fontSize: "0.8em" }}>{t("tariff.tierOvernight")}</span>
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||
)}
|
||||
</span>
|
||||
<label>{t("tariff.tierDates")}</label>
|
||||
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
|
||||
<input type="date" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||
<span>–</span>
|
||||
<input type="date" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||
<label className="label">{t("tariff.tierDates")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={tr.pricing}
|
||||
@@ -386,19 +390,19 @@ export function TariffComposer() {
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<button type="button" onClick={addTier}>
|
||||
<button type="button" className="btn btn-sm" onClick={addTier}>
|
||||
{t("tariff.addTier")}
|
||||
</button>
|
||||
</details>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button type="button" onClick={publish} disabled={saving}>
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||
</button>
|
||||
{msg && (
|
||||
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
|
||||
)}
|
||||
</div>
|
||||
{msg && (
|
||||
<p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson", marginTop: "0.5rem" }}>{msg.text}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -417,29 +421,29 @@ function PricingEditor(props: {
|
||||
const { t, pricing: p } = props;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: "1rem", marginBottom: "0.4rem", fontSize: "0.9em" }}>
|
||||
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
|
||||
<input type="radio" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
<div className="mb-3 flex gap-4 text-[12px]">
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
{t("tariff.modeLadder")}
|
||||
</label>
|
||||
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
|
||||
<input type="radio" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
{t("tariff.modeFlat")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{p.mode === "flat" ? (
|
||||
<div style={{ display: "inline-flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<span style={{ color: "#777" }}>{t("tariff.pricePerIncrement")}</span>
|
||||
<input value={p.flat} onChange={(e) => props.onFlat(e.target.value)} style={{ width: 90 }} />
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||
<tr className="text-left">
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -448,32 +452,38 @@ function PricingEditor(props: {
|
||||
const isTail = i === p.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<td className="px-2 py-1">
|
||||
{isTail ? (
|
||||
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
|
||||
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
|
||||
<input value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} style={{ width: 70 }} />
|
||||
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||
<td className="px-2 py-1">
|
||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{!isTail && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td>{!isTail && <button type="button" onClick={() => props.onRemoveBlock(i)}>{t("tariff.remove")}</button>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: "0.4rem", display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<button type="button" onClick={props.onAddBlock}>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center", fontSize: "0.9em" }}>
|
||||
<span style={{ color: "#777" }}>{t("tariff.dailyCap")}</span>
|
||||
<input value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} style={{ width: 90 }} />
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.dailyCap")}</span>
|
||||
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+136
-48
@@ -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<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||
|
||||
const invalidate = () => void qc.invalidateQueries({ queryKey: ["users"] });
|
||||
const onError = (e: unknown) =>
|
||||
@@ -43,11 +45,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
||||
<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>
|
||||
{canCreate && roles.length > 0 && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => { setAdding(true); setError(null); }}>
|
||||
{t("users.add")}
|
||||
</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>}
|
||||
|
||||
{adding && (
|
||||
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
|
||||
<UserForm
|
||||
roles={roles}
|
||||
onCancel={() => 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); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
<table className="w-full text-[12px]">
|
||||
@@ -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 (
|
||||
<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">
|
||||
{canUpdate ? (
|
||||
<select
|
||||
value={u.roleId}
|
||||
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>)}
|
||||
</select>
|
||||
@@ -149,8 +184,12 @@ function UserRow({
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{canUpdate && !resetting && (
|
||||
<button type="button" onClick={() => setResetting(true)}
|
||||
className="text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onEdit}>
|
||||
{t("users.edit")}
|
||||
</button>
|
||||
)}
|
||||
{canUpdate && !resetting && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setResetting(true)}>
|
||||
{t("users.resetPassword")}
|
||||
</button>
|
||||
)}
|
||||
@@ -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"
|
||||
/>
|
||||
<button type="button" disabled={pw.length < 8 || pwMut.isPending} onClick={() => pwMut.mutate()}
|
||||
className="text-[11px] uppercase tracking-wider text-term-green disabled:opacity-40">
|
||||
<button type="button" className="btn btn-go btn-sm" disabled={pw.length < 8 || pwMut.isPending} onClick={() => pwMut.mutate()}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setResetting(false); setPw(""); }}
|
||||
className="text-[11px] uppercase tracking-wider text-term-muted">✕</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setResetting(false); setPw(""); }}>✕</button>
|
||||
</span>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
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")}
|
||||
</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({
|
||||
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 (
|
||||
<div className="mb-3 rounded-term border border-term-border bg-term-panel p-3">
|
||||
<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-2">
|
||||
<label className="text-[11px] text-term-muted">
|
||||
{t("users.username")}
|
||||
<input value={username} onChange={(e) => setUsername(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" />
|
||||
</label>
|
||||
<label className="text-[11px] text-term-muted">
|
||||
{t("users.password")}
|
||||
<input type="password" value={password} onChange={(e) => setPassword(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" />
|
||||
</label>
|
||||
<label className="text-[11px] text-term-muted">
|
||||
{t("users.role")}
|
||||
<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">
|
||||
<div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("users.username")}</span>
|
||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div className="field">
|
||||
<span className="label">{t("users.password")}</span>
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<span className="label">{t("users.role")}</span>
|
||||
<select className="select" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-term-muted">{t("users.passwordHint")}</div>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<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>
|
||||
<button type="button" disabled={!valid} onClick={() => onSubmit({ username: username.trim(), password, roleId })}
|
||||
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>
|
||||
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
|
||||
|
||||
{/* Optional profile metadata. */}
|
||||
<div className="mt-4 mb-2 text-[11px] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
+58
-3
@@ -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<SessionUser | null> {
|
||||
try {
|
||||
@@ -92,7 +102,14 @@ export async function fetchMe(): Promise<SessionUser | null> {
|
||||
|
||||
// --- 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<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) });
|
||||
}
|
||||
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) });
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -194,3 +194,218 @@ body {
|
||||
outline: 1px solid var(--color-term-amber);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COMPONENT LAYER
|
||||
The TRM tokens are good, but every screen hand-rolled inputs and
|
||||
buttons as bare outlines on near-black panels, so a field, a card,
|
||||
and a button were visually indistinguishable. These classes give
|
||||
each control a real identity:
|
||||
- inputs read as RECESSED slots (lighter fill + inset shadow)
|
||||
- the primary button is FILLED (accent body, dark text) — the
|
||||
one obvious action; neutrals are filled grey, not bare outlines
|
||||
- explicit hover / active / focus / disabled states everywhere
|
||||
Use @apply so the classes compose with Tailwind utilities.
|
||||
============================================================ */
|
||||
@layer components {
|
||||
/* ---- Form fields: a recessed slot, clearly an input ---- */
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
@apply w-full rounded-term border bg-term-bg px-2.5 text-term-text
|
||||
placeholder:text-term-muted;
|
||||
border-color: #3a414c; /* lighter than panel borders */
|
||||
height: var(--control-h-md);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||
transition: border-color 120ms var(--ease-snap), box-shadow 120ms var(--ease-snap);
|
||||
}
|
||||
.textarea {
|
||||
height: auto;
|
||||
@apply py-2 leading-snug;
|
||||
}
|
||||
.input::placeholder,
|
||||
.textarea::placeholder {
|
||||
@apply text-term-muted;
|
||||
}
|
||||
.input:hover,
|
||||
.select:hover,
|
||||
.textarea:hover {
|
||||
border-color: #4a525f;
|
||||
}
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-term-amber);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px var(--color-term-amber);
|
||||
}
|
||||
.input:disabled,
|
||||
.select:disabled,
|
||||
.textarea:disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
/* Small / dense variant for inline table cells */
|
||||
.input-sm {
|
||||
height: var(--control-h-sm);
|
||||
@apply px-2 text-[12px];
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
.label {
|
||||
@apply text-[11px] uppercase tracking-wider text-term-muted;
|
||||
}
|
||||
.hint {
|
||||
@apply text-[11px] leading-snug text-term-muted;
|
||||
}
|
||||
|
||||
/* ---- Buttons: a button must look pressable, never like a field ---- */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-term border
|
||||
px-3 text-[12px] font-semibold uppercase tracking-wider
|
||||
transition-colors select-none;
|
||||
height: var(--control-h-md);
|
||||
/* Neutral default: a filled grey body, not a bare outline. */
|
||||
background: var(--color-term-panel-2);
|
||||
border-color: #3a414c;
|
||||
color: var(--color-term-text);
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #2a313b;
|
||||
border-color: #4a525f;
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.btn:disabled {
|
||||
@apply cursor-not-allowed opacity-40;
|
||||
}
|
||||
.btn-sm {
|
||||
height: var(--control-h-sm);
|
||||
@apply px-2.5 text-[11px];
|
||||
}
|
||||
.btn-lg {
|
||||
height: var(--control-h-lg);
|
||||
@apply px-5 text-[13px];
|
||||
}
|
||||
|
||||
/* Primary: FILLED amber, dark text — the unmistakable main action. */
|
||||
.btn-primary {
|
||||
background: var(--color-term-amber);
|
||||
border-color: var(--color-term-amber);
|
||||
color: #0b0d10;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #ffb733;
|
||||
border-color: #ffb733;
|
||||
}
|
||||
|
||||
/* Semantic filled variants (entry / payment / destructive). */
|
||||
.btn-go {
|
||||
background: var(--color-term-green);
|
||||
border-color: var(--color-term-green);
|
||||
color: #f2f2ee;
|
||||
}
|
||||
.btn-go:hover:not(:disabled) {
|
||||
background: #38a85a;
|
||||
border-color: #38a85a;
|
||||
}
|
||||
.btn-pay {
|
||||
background: var(--color-term-cyan);
|
||||
border-color: var(--color-term-cyan);
|
||||
color: #f2f2ee;
|
||||
}
|
||||
.btn-pay:hover:not(:disabled) {
|
||||
background: #2f74e0;
|
||||
border-color: #2f74e0;
|
||||
}
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border-color: var(--color-term-red);
|
||||
color: var(--color-term-red);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-term-red) 14%, transparent);
|
||||
}
|
||||
|
||||
/* Ghost: lowest-emphasis (cancel, secondary nav) — text + hover only. */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--color-term-muted);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--color-term-panel-2);
|
||||
color: var(--color-term-text);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ---- Card: a panel that is clearly a container, not a field ---- */
|
||||
.card {
|
||||
@apply rounded-term border border-term-border bg-term-panel;
|
||||
}
|
||||
.card-head {
|
||||
@apply flex items-center justify-between border-b border-term-border
|
||||
bg-term-panel-2 px-4 py-2 text-[12px] uppercase tracking-wider text-term-muted;
|
||||
}
|
||||
.card-body {
|
||||
@apply p-4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
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",
|
||||
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",
|
||||
|
||||
+119
-7
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+172
-26
@@ -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 (
|
||||
<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)
|
||||
* 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 (
|
||||
<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:
|
||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||
@@ -167,23 +245,28 @@ function RootLayout() {
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shift" label={t("nav.shift")} />
|
||||
{show("site:update") && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
{show("tariff:read") && <NavLink to="/tariff" label={t("nav.tariff")} />}
|
||||
{show("subscription:read") && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
|
||||
{show("site:read") && <NavLink to="/site" label={t("nav.site")} />}
|
||||
{show("user:read") && <NavLink to="/users" label={t("nav.users")} />}
|
||||
{show("role:read") && <NavLink to="/roles" label={t("nav.roles")} />}
|
||||
{/* One Setup entry — its tabs hold devices/tariff/subscriptions/site/users/
|
||||
roles/shifts. Shown if the user can reach ANY of those screens (an
|
||||
operator with only shift:read still gets in, landing on Shifts). */}
|
||||
{(show("site:update") ||
|
||||
show("tariff:read") ||
|
||||
show("subscription:read") ||
|
||||
show("site:read") ||
|
||||
show("user:read") ||
|
||||
show("role:read") ||
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
<span className="text-[11px] text-term-muted">
|
||||
{user?.username} · {user?.roleName}
|
||||
</span>
|
||||
<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 () => {
|
||||
await logout();
|
||||
setUser(null);
|
||||
@@ -216,6 +299,26 @@ const boothRoute = createRoute({
|
||||
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({
|
||||
getParentRoute: () => rootRoute,
|
||||
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({
|
||||
getParentRoute: () => rootRoute,
|
||||
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 />,
|
||||
});
|
||||
const tariffRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/tariff",
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "tariff",
|
||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||
component: () => <TariffComposer />,
|
||||
});
|
||||
const subscriptionsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/subscriptions",
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "subscriptions",
|
||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
||||
component: () => <SubscriptionManager />,
|
||||
});
|
||||
const siteRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/site",
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "site",
|
||||
beforeLoad: ({ context }) => requirePerm("site:read")(context),
|
||||
component: function SiteRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
@@ -263,8 +394,8 @@ const siteRoute = createRoute({
|
||||
},
|
||||
});
|
||||
const usersRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/users",
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "users",
|
||||
beforeLoad: ({ context }) => requirePerm("user:read")(context),
|
||||
component: function UsersRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
@@ -272,25 +403,40 @@ const usersRoute = createRoute({
|
||||
},
|
||||
});
|
||||
const rolesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/roles",
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "roles",
|
||||
beforeLoad: ({ context }) => requirePerm("role:read")(context),
|
||||
component: function RolesRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
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([
|
||||
indexRoute,
|
||||
boothRoute,
|
||||
...legacyRedirects,
|
||||
shiftRoute,
|
||||
setupRoute,
|
||||
tariffRoute,
|
||||
subscriptionsRoute,
|
||||
siteRoute,
|
||||
usersRoute,
|
||||
rolesRoute,
|
||||
setupRoute.addChildren([
|
||||
setupDevicesRoute,
|
||||
tariffRoute,
|
||||
subscriptionsRoute,
|
||||
siteRoute,
|
||||
usersRoute,
|
||||
rolesRoute,
|
||||
shiftsHistoryRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Reusable modal shell — a thin wrapper over Radix Dialog matching the terminal
|
||||
// chrome (title bar + ✕, dark overlay, square panel). The same styling BoothPayModal
|
||||
// uses inline, factored out so every popped-out form looks identical. Radix handles
|
||||
// focus trap, Escape, and outside-click → onClose. `width` is a Tailwind max-width
|
||||
// class (the panel is responsive: w-full up to that cap).
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
width = "max-w-xl",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
/** Tailwind max-width class for the panel (default max-w-xl). */
|
||||
width?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
|
||||
<Dialog.Content
|
||||
className={`fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[95vw] ${width} -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl`}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
||||
✕
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
"tag": "0007_rbac",
|
||||
"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"] })
|
||||
.notNull()
|
||||
.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")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
|
||||
Reference in New Issue
Block a user