Initial commit
This commit is contained in:
622
src/features/store-admin/PeopleDrawers.tsx
Normal file
622
src/features/store-admin/PeopleDrawers.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Check, Copy, X } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
|
||||
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { branchLabel } from './format';
|
||||
|
||||
/* ── Shell ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The sheet both drawers sit in.
|
||||
*
|
||||
* Right-hand rather than centred, and Escape closes it — the same contract as
|
||||
* the order drawer, so the two do not behave differently for no reason.
|
||||
*/
|
||||
function Sheet({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgb(15 23 42 / .32)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="dialog-panel"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 440,
|
||||
maxWidth: '92vw',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{ padding: 20, borderBottom: '1px solid var(--color-line)', flex: 'none' }}
|
||||
>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</HStack>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>{children}</div>
|
||||
|
||||
<HStack
|
||||
justify="between"
|
||||
align="center"
|
||||
gap={1}
|
||||
style={{ padding: 16, borderTop: '1px solid var(--color-line)', flex: 'none' }}
|
||||
>
|
||||
{footer}
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Problem({ message }: { message: string }) {
|
||||
return (
|
||||
<HStack
|
||||
gap={1}
|
||||
align="start"
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
background: 'color-mix(in oklab, var(--color-error, #d64545) 8%, transparent)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 1 }}
|
||||
/>
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{message}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Back-office person ──────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Roles a Store Admin may hand out.
|
||||
*
|
||||
* Hardcoded, and that is a stopgap rather than a choice: `app_roles` lives
|
||||
* outside Fiesta, so there is no first-party endpoint to read. The ids are the
|
||||
* ones `resolveRole` already keys on. Editing an existing person shows whatever
|
||||
* `getstaffs` resolved, even when it is not in this list, because the backend's
|
||||
* label is authoritative and most accounts carry an id absent from `app_roles`.
|
||||
*/
|
||||
const STAFF_ROLES = [
|
||||
{ id: 3, label: 'Administrator' },
|
||||
{ id: 4, label: 'Manager' },
|
||||
{ id: 5, label: 'Staff' },
|
||||
];
|
||||
|
||||
export function PersonDrawer({
|
||||
row,
|
||||
tenantid,
|
||||
branches,
|
||||
onClose,
|
||||
}: {
|
||||
row: StaffInfo | null;
|
||||
tenantid: number;
|
||||
branches: TenantLocation[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isNew = row === null;
|
||||
|
||||
const [firstname, setFirstname] = useState(row?.firstname ?? '');
|
||||
const [lastname, setLastname] = useState(row?.lastname ?? '');
|
||||
const [email, setEmail] = useState(row?.email ?? '');
|
||||
const [contactno, setContactno] = useState(row?.contactno ?? '');
|
||||
const [roleid, setRoleid] = useState(String(row?.roleid ?? STAFF_ROLES[1]?.id ?? 4));
|
||||
const [locationid, setLocationid] = useState(
|
||||
String(row?.locationid ?? branches[0]?.locationid ?? 0),
|
||||
);
|
||||
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const status = isActive ? 'Active' : 'Inactive';
|
||||
if (isNew) {
|
||||
return staffApi.create({
|
||||
tenantid,
|
||||
locationid: Number(locationid),
|
||||
firstname: firstname.trim(),
|
||||
lastname: lastname.trim(),
|
||||
email: email.trim(),
|
||||
contactno: normaliseMobile(contactno),
|
||||
roleid: Number(roleid),
|
||||
status,
|
||||
});
|
||||
}
|
||||
return staffApi.update({
|
||||
userid: row.userid,
|
||||
firstname: firstname.trim(),
|
||||
lastname: lastname.trim(),
|
||||
email: email.trim(),
|
||||
contactno: normaliseMobile(contactno),
|
||||
// Never send a non-positive roleid. `UpdateStaff` writes whatever it is
|
||||
// given, so a 0 or -1 here lands in the column as a role nothing matches.
|
||||
roleid: Number(roleid) > 0 ? Number(roleid) : undefined,
|
||||
locationid: Number(locationid) || undefined,
|
||||
status,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.people.all });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setProblem(null);
|
||||
if (!firstname.trim()) return setProblem('A first name is required.');
|
||||
if (!email.trim()) return setProblem('An email is required — it is what they sign in with.');
|
||||
save.mutate();
|
||||
}
|
||||
|
||||
// An existing role the picker does not know about is still shown, so editing
|
||||
// someone's phone number cannot silently reassign their role.
|
||||
const roleOptions = [
|
||||
...STAFF_ROLES.map((role) => ({ value: String(role.id), label: role.label })),
|
||||
...(row?.roleid && !STAFF_ROLES.some((role) => role.id === row.roleid)
|
||||
? [{ value: String(row.roleid), label: row.rolename || `Role ${row.roleid}` }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={isNew ? 'Add a person' : (row.fullname ?? 'Edit person')}
|
||||
subtitle={isNew ? 'They will be able to sign in to this console' : `User ${row.userid}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
variant="primary"
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<VStack gap={2}>
|
||||
{problem ? <Problem message={problem} /> : null}
|
||||
|
||||
<div className="form-grid">
|
||||
<TextInput label={<span>First name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={firstname} onChange={setFirstname} />
|
||||
<TextInput label="Last name" value={lastname} onChange={setLastname} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label={<span>Email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
description="What they sign in with."
|
||||
/>
|
||||
<TextInput label="Mobile" value={contactno} onChange={setContactno} />
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector label="Role" value={roleid} onChange={setRoleid} options={roleOptions} />
|
||||
<Selector
|
||||
label="Branch"
|
||||
value={locationid}
|
||||
onChange={setLocationid}
|
||||
options={branches.map((branch) => ({
|
||||
value: String(branch.locationid),
|
||||
label: branchLabel(branch.locationname),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActiveToggle
|
||||
isActive={isActive}
|
||||
onChange={setIsActive}
|
||||
activeNote="Can sign in"
|
||||
inactiveNote="Cannot sign in. History is kept."
|
||||
/>
|
||||
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
No password is set here. Passwords are stored in clear in Fiesta, so issuing one is a
|
||||
backend job until that changes.
|
||||
</Text>
|
||||
</VStack>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Till account ────────────────────────────────────────────────────────── */
|
||||
|
||||
export function TillDrawer({
|
||||
row,
|
||||
tenantid,
|
||||
locationid,
|
||||
roles,
|
||||
shifts,
|
||||
onClose,
|
||||
}: {
|
||||
row: PosUser | null;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
roles: PosRole[];
|
||||
shifts: StaffShift[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isNew = row === null;
|
||||
|
||||
const [fullName, setFullName] = useState(row?.full_name ?? '');
|
||||
const [role, setRole] = useState(row?.role?.toLowerCase() ?? 'cashier');
|
||||
const [contactno, setContactno] = useState(row?.contactno ?? '');
|
||||
const [pin, setPin] = useState('');
|
||||
const [shiftId, setShiftId] = useState(String(row?.shift_id ?? 0));
|
||||
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
/** The credential the backend hands back on create. Shown once, never re-read. */
|
||||
const [issued, setIssued] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const status = isActive ? 'Active' : 'Inactive';
|
||||
if (isNew) {
|
||||
return posUsersApi.create({
|
||||
tenantid,
|
||||
locationid,
|
||||
full_name: fullName.trim(),
|
||||
role,
|
||||
contactno: normaliseMobile(contactno),
|
||||
pin: pin.trim() || undefined,
|
||||
shift_id: Number(shiftId) || undefined,
|
||||
status,
|
||||
});
|
||||
}
|
||||
return posUsersApi.update({
|
||||
tenantid,
|
||||
locationid,
|
||||
user_id: row.user_id,
|
||||
full_name: fullName.trim(),
|
||||
role,
|
||||
contactno: normaliseMobile(contactno),
|
||||
shift_id: Number(shiftId) || undefined,
|
||||
status,
|
||||
});
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.people.all });
|
||||
// The backend returns the PIN or password only in this answer, never in a
|
||||
// listing. If it came back, this is the one moment it can be read.
|
||||
const credential = (result as PosUser | undefined)?.pin ?? (result as PosUser | undefined)?.password;
|
||||
if (isNew && credential) setIssued(credential);
|
||||
else onClose();
|
||||
},
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setProblem(null);
|
||||
if (!fullName.trim()) return setProblem('A name is required.');
|
||||
const mobile = normaliseMobile(contactno);
|
||||
if (mobile.length !== 10) {
|
||||
return setProblem('A ten-digit mobile is required — the till matches on it exactly.');
|
||||
}
|
||||
if (isNew && pin.trim() && !/^\d{4,6}$/.test(pin.trim())) {
|
||||
return setProblem('A PIN is 4 to 6 digits.');
|
||||
}
|
||||
save.mutate();
|
||||
}
|
||||
|
||||
if (issued) {
|
||||
return (
|
||||
<Sheet
|
||||
title="Account created"
|
||||
subtitle={fullName}
|
||||
onClose={onClose}
|
||||
footer={<Button label="Done" variant="primary" onClick={onClose} />}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
<Text type="body" size="sm">
|
||||
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it
|
||||
is lost, reissue rather than look it up.
|
||||
</Text>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.18em',
|
||||
}}
|
||||
>
|
||||
{issued}
|
||||
</span>
|
||||
<Button
|
||||
label="Copy"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Copy size={13} />}
|
||||
onClick={() => void navigator.clipboard?.writeText(issued)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const roleOptions =
|
||||
roles.length > 0
|
||||
? roles.map((entry) => ({ value: entry.role, label: entry.label }))
|
||||
: // Only if `posroles` is unreachable. The endpoint is the source; this is
|
||||
// a fallback so the form is not unusable, not a second definition.
|
||||
[
|
||||
{ value: 'supervisor', label: 'Supervisor' },
|
||||
{ value: 'cashier', label: 'Cashier' },
|
||||
];
|
||||
|
||||
const chosenRole = roles.find((entry) => entry.role === role);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={isNew ? 'Add a till account' : (row.full_name ?? 'Edit till account')}
|
||||
subtitle={
|
||||
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
|
||||
}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
variant="primary"
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<VStack gap={2}>
|
||||
{problem ? <Problem message={problem} /> : null}
|
||||
|
||||
<TextInput label={<span>Full name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={fullName} onChange={setFullName} />
|
||||
|
||||
<Selector label="Role" value={role} onChange={setRole} options={roleOptions} />
|
||||
{chosenRole?.description ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{chosenRole.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label={<span>Mobile <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={contactno}
|
||||
onChange={setContactno}
|
||||
description="Ten digits. This is what they sign in with at the till."
|
||||
/>
|
||||
|
||||
{isNew ? (
|
||||
<TextInput
|
||||
label="PIN"
|
||||
value={pin}
|
||||
onChange={setPin}
|
||||
description="4–6 digits. Leave blank and the backend issues one — either way it is shown once."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Selector
|
||||
label="Shift"
|
||||
value={shiftId}
|
||||
onChange={setShiftId}
|
||||
options={[
|
||||
{ value: '0', label: 'Any shift' },
|
||||
...shifts.map((shift) => ({
|
||||
value: String(shift.staff_shift_id),
|
||||
label: `${shift.name} · ${shift.start_time}–${shift.end_time} · ${weekdayLabel(shift.weekdays)}`,
|
||||
})),
|
||||
]}
|
||||
description="Informational. Nothing refuses a bill rung outside it."
|
||||
/>
|
||||
|
||||
<ActiveToggle
|
||||
isActive={isActive}
|
||||
onChange={setIsActive}
|
||||
activeNote="Can open a till"
|
||||
inactiveNote="Cannot sign in at a till. Their bills are kept."
|
||||
/>
|
||||
</VStack>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Active / inactive, as a pair of buttons rather than a checkbox.
|
||||
*
|
||||
* Deactivating is the closest thing this console has to a delete, so it says
|
||||
* what it does in words on the control itself instead of leaving the
|
||||
* consequence to a checkbox label the eye skips.
|
||||
*/
|
||||
function ActiveToggle({
|
||||
isActive,
|
||||
onChange,
|
||||
activeNote,
|
||||
inactiveNote,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
activeNote: string;
|
||||
inactiveNote: string;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
Access
|
||||
</Text>
|
||||
<HStack gap={1}>
|
||||
<Choice
|
||||
label="Active"
|
||||
note={activeNote}
|
||||
isSelected={isActive}
|
||||
onSelect={() => onChange(true)}
|
||||
/>
|
||||
<Choice
|
||||
label="Inactive"
|
||||
note={inactiveNote}
|
||||
isSelected={!isActive}
|
||||
onSelect={() => onChange(false)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
label,
|
||||
note,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
note: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={isSelected}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${isSelected ? 'var(--color-brand)' : 'var(--color-line)'}`,
|
||||
background: isSelected ? 'var(--color-brand-tint)' : 'var(--color-surface)',
|
||||
transition: 'border-color .15s, background .15s',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: isSelected ? 'var(--color-brand)' : 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{isSelected ? <Check size={12} /> : null}
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)', marginTop: 2 }}>
|
||||
{note}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user