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 (
{title}
{subtitle ? (
{subtitle}
) : null}
{children}
{footer}
);
}
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.
*/
const STAFF_ROLES = [
{ id: 3, label: 'Administrator — runs the whole business' },
{ 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 (
);
}
/* ── 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(null);
/** The credential the backend hands back on create. Shown once, never re-read. */
const [issued, setIssued] = useState(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 (
}
>
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.
{issued}
}
onClick={() => void navigator.clipboard?.writeText(issued)}
/>
);
}
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 (
);
}
/* ── 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 (
Access
onChange(true)}
/>
onChange(false)}
/>
);
}
function Choice({
label,
note,
isSelected,
onSelect,
}: {
label: string;
note: string;
isSelected: boolean;
onSelect: () => void;
}) {
return (
);
}