Files
daily_console_web/src/features/store-admin/PeopleDrawers.tsx
2026-09-07 13:02:35 +05:30

612 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}