612 lines
21 KiB
TypeScript
612 lines
21 KiB
TypeScript
import { 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 } 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 { Drawer } from './Drawer';
|
||
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;
|
||
}) {
|
||
return (
|
||
<Drawer
|
||
title={title}
|
||
width={440}
|
||
onClose={onClose}
|
||
footer={footer}
|
||
isFooterSpread
|
||
{...(subtitle ? { subtitle } : {})}
|
||
>
|
||
{children}
|
||
</Drawer>
|
||
);
|
||
}
|
||
|
||
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`.
|
||
*/
|
||
/**
|
||
* The back-office roles, and what each one can actually reach.
|
||
*
|
||
* The labels used to be bare — Administrator, Manager, Staff — and the
|
||
* difference between them is not a matter of seniority, it is which workspace
|
||
* the account lands in. `resolveRole` sends roleids 1 and 3 to the merchant
|
||
* console; everything else becomes a branch user, pinned to one shop.
|
||
*
|
||
* Somebody choosing "Staff" for a person they meant to be a second
|
||
* administrator gets an account that signs in to the wrong workspace, and
|
||
* nothing on the way there says so. Naming the consequence in the option is the
|
||
* cheapest possible fix.
|
||
*/
|
||
/*
|
||
* Administrator (roleid 3) is NOT offered here, and its absence is the point.
|
||
*
|
||
* An administrator is provisioned by Nearle Admin when the business is created
|
||
* — `createtenantuser` writes that account and forces the role — so offering it
|
||
* here was a second, unguarded way to mint one, from inside the workspace it
|
||
* grants the run of.
|
||
*
|
||
* Editing an existing administrator is unaffected: `roleOptions` below appends
|
||
* whatever role the person already holds when this list does not know it, which
|
||
* exists precisely so editing a phone number cannot silently reassign someone.
|
||
* Removing the option from CREATION therefore costs nothing on the edit path.
|
||
*/
|
||
const STAFF_ROLES = [{ id: 4, label: 'Manager — one shop' }];
|
||
|
||
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));
|
||
/**
|
||
* No branch by default, rather than the first one.
|
||
*
|
||
* `branches[0]` was a guess, and it is the wrong one twice over. It quietly
|
||
* posted a new hire to whichever outlet happens to sort first, and it made
|
||
* "not at a shop yet" unreachable — the state a person is in between being
|
||
* hired and their branch opening, which is the whole point of being able to
|
||
* add somebody before the shop exists.
|
||
*
|
||
* An existing person keeps whatever they have; only a new one starts unplaced.
|
||
*/
|
||
const [locationid, setLocationid] = useState(String(row?.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}
|
||
placeholder="Not at a shop yet"
|
||
description="Leave this if their shop does not exist yet — place them when it opens."
|
||
options={[
|
||
/* An offered choice, not an absence. Somebody hired before
|
||
their outlet opens sits here, signs in, and is met by the
|
||
"No store assigned" screen until a branch is ready for
|
||
them — which is what makes hiring before opening possible. */
|
||
{ value: '0', label: 'Not at a shop yet' },
|
||
...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,
|
||
cashierOnly = false,
|
||
onClose,
|
||
}: {
|
||
row: PosUser | null;
|
||
tenantid: number;
|
||
locationid: number;
|
||
roles: PosRole[];
|
||
shifts: StaffShift[];
|
||
/**
|
||
* True in the Store user workspace.
|
||
*
|
||
* A shop provisions cashiers, not supervisors. The backend agrees from the
|
||
* other side — only roleid 7 may manage till users — so a supervisor is
|
||
* issued by the merchant and creates their own cashiers at the terminal. The
|
||
* picker is absent rather than disabled: there is nothing to choose.
|
||
*/
|
||
cashierOnly?: boolean;
|
||
onClose: () => void;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const isNew = row === null;
|
||
|
||
const [fullName, setFullName] = useState(row?.full_name ?? '');
|
||
const [role, setRole] = useState(cashierOnly ? 'cashier' : (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
|
||
? cashierOnly
|
||
? 'Add a cashier'
|
||
: 'Add a terminal account'
|
||
: (row.full_name ?? 'Edit terminal 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} />
|
||
|
||
{/* The role is shown either way — a form that silently decides a
|
||
field leaves the reader wondering what was decided. A shop just
|
||
cannot change it: the merchant issues supervisors, and the backend
|
||
only lets roleid 7 manage till users at all. */}
|
||
{cashierOnly ? (
|
||
<Selector
|
||
label="Role"
|
||
value="cashier"
|
||
onChange={() => undefined}
|
||
options={[{ value: 'cashier', label: 'Cashier' }]}
|
||
isDisabled
|
||
description="Supervisors are issued by your store administrator."
|
||
/>
|
||
) : (
|
||
<>
|
||
<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>
|
||
);
|
||
}
|