Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

View 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>
);
}