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 ( {children} ); } function Problem({ message }: { message: string }) { return ( {message} ); } /* ── 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(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 ( ); }