feat: tabbed setup, user metadata, light theme, scoped shift history
Consolidate the config screens under a single /setup hub with permission- gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing the top nav to Booth·Shift·Setup; old top-level paths redirect. Users: add optional profile metadata (full name, phone, email, address) on create/edit. Theme: a light palette saved to the user's profile (users.theme), toggled in the header beside the language switch and applied on load like the language preference. Both ride on a single additive migration (0008). Shift history: a new GET /api/shifts folds the signed shift_z_report chain into completed shifts, SCOPED server-side — operators see only their own; holders of shift:cash see all with an operator + date-range filter. Surfaced as the Shifts tab; an operator cannot read another operator's takings (param spoofing is ignored). These three features share the router, api client and i18n catalogs, so they land together. Verified live: theme persists across reload, metadata round- trips to the DB, and shift scoping holds (operator self-only, admin all+filter). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user