diff --git a/src/components/PosStaffPanel.tsx b/src/components/PosStaffPanel.tsx index dcdda2f..8751278 100644 --- a/src/components/PosStaffPanel.tsx +++ b/src/components/PosStaffPanel.tsx @@ -24,6 +24,7 @@ import { AlertTriangle, Check, Copy, + Clock, KeyRound, Pencil, Plus, @@ -36,6 +37,7 @@ import { POS_ROLE_CASHIER, generatePosPassword, type PosRoleOption, + type PosStaffShift, type PosStaffUser, } from '../services/fiestaApi'; import { @@ -43,7 +45,10 @@ import { useDeactivatePosStaff, usePosRoles, usePosStaff, + useCreateStaffShift, + useStaffShifts, useUpdatePosStaff, + useUpdateStaffShift, } from '../services/fiestaQueries'; import { BORDER, @@ -89,6 +94,7 @@ export default function PosStaffPanel({ }: PosStaffPanelProps) { const staffQ = usePosStaff(tenantId, locationid); const rolesQ = usePosRoles(); + const shiftsQ = useStaffShifts(tenantId, locationid); const createMut = useCreatePosStaff(); const updateMut = useUpdatePosStaff(); const deactivateMut = useDeactivatePosStaff(); @@ -97,6 +103,8 @@ export default function PosStaffPanel({ const [fullName, setFullName] = useState(''); const [role, setRole] = useState(''); const [pin, setPin] = useState(''); + const [phone, setPhone] = useState(''); + const [shiftId, setShiftId] = useState(0); const [formError, setFormError] = useState(''); const [issued, setIssued] = useState(null); const [copied, setCopied] = useState(false); @@ -109,6 +117,8 @@ export default function PosStaffPanel({ const [editName, setEditName] = useState(''); const [editRole, setEditRole] = useState(''); const [editPin, setEditPin] = useState(''); + const [editPhone, setEditPhone] = useState(''); + const [editShift, setEditShift] = useState(0); const [editError, setEditError] = useState(''); const [confirmReset, setConfirmReset] = useState(null); const [rowError, setRowError] = useState(''); @@ -125,6 +135,8 @@ export default function PosStaffPanel({ setFullName(''); setRole(''); setPin(''); + setPhone(''); + setShiftId(0); setFormError(''); }; @@ -140,10 +152,26 @@ export default function PosStaffPanel({ if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) { return setFormError('Another active person at this outlet already uses that PIN.'); } + // The till signs in with this, so it is required here even though the + // column allows blank — an account without one can only ever log in by + // username, which is what this change exists to stop relying on. + const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, ''); + if (digits.length !== 10) return setFormError('Enter a 10-digit mobile number.'); + if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) { + return setFormError('Another active person here already signs in with that number.'); + } setFormError(''); createMut.mutate( - { tenantid: tenantId, locationid, full_name: name, role: chosen, pin }, + { + tenantid: tenantId, + locationid, + full_name: name, + role: chosen, + pin, + contactno: digits, + shift_id: shiftId || undefined, + }, { onSuccess: (created) => { setIssued(created); @@ -161,6 +189,8 @@ export default function PosStaffPanel({ setEditName(s.full_name || ''); setEditRole(s.role_id === POS_ROLE_CASHIER ? 'cashier' : 'supervisor'); setEditPin(s.pin || ''); + setEditPhone(s.contactno || ''); + setEditShift(s.shift_id || 0); setEditError(''); }; @@ -180,6 +210,8 @@ export default function PosStaffPanel({ // role as a change, and a no-op write still moves the row. role: editRole && editRole !== (editing.role || '').toLowerCase() ? editRole : undefined, pin: editPin && editPin !== editing.pin ? editPin : undefined, + contactno: editPhone && editPhone !== (editing.contactno || '') ? editPhone : undefined, + shift_id: editShift && editShift !== (editing.shift_id || 0) ? editShift : undefined, }, { onSuccess: () => setEditing(null), @@ -241,6 +273,13 @@ export default function PosStaffPanel({ return ( <> + +
@@ -293,8 +332,35 @@ export default function PosStaffPanel({ />
+
+ setPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))} + placeholder="Mobile number — used to sign in" + inputMode="tel" + className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono" + style={{ border: `1px solid ${BORDER}`, color: TEXT }} + /> + +
+

- A username and password are generated. The password is shown once, on the next screen. + The mobile number is what they type at the till. A username and password are also + generated; the password is shown once, on the next screen.

{formError && ( @@ -345,11 +411,13 @@ export default function PosStaffPanel({ ) : (
- +
+ + @@ -369,6 +437,12 @@ export default function PosStaffPanel({ color={s.role_id === POS_ROLE_CASHIER ? BRAND : '#0ea5e9'} /> + +
Name RoleMobileShift Username PIN Password + {s.contactno || 'not set'} + + {s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'} + {s.authname || '—'} {s.pin || '—'} @@ -455,6 +529,27 @@ export default function PosStaffPanel({ className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono tracking-widest" style={{ border: `1px solid ${BORDER}`, color: TEXT }} /> + setEditPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))} + placeholder="Mobile number — used to sign in" + inputMode="tel" + className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono" + style={{ border: `1px solid ${BORDER}`, color: TEXT }} + /> + {editError &&

{editError}

}
@@ -519,6 +614,7 @@ export default function PosStaffPanel({
+ @@ -589,6 +685,224 @@ export default function PosStaffPanel({ ); } +const WEEKDAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + +/** + * The shift windows an outlet runs. + * + * Sits above the staff list because it has to: the shift picker on the staff + * form can only offer what exists here, and without this screen that dropdown + * reads "No shifts set up yet" for ever. + * + * Deliberately small. A shift is a name and two times — the moment it grows a + * roster of dated assignments it wants its own page, but a shop that runs a + * morning and an evening should not have to visit one. + */ +function ShiftManager({ + tenantId, + locationid, + shifts, + loading, +}: { + tenantId: number; + locationid: number; + shifts: PosStaffShift[]; + loading: boolean; +}) { + const createMut = useCreateStaffShift(); + const updateMut = useUpdateStaffShift(); + + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + // All seven on by default: a shop that never varies its week should not have + // to say so, and the server treats an all-ones mask and an empty one alike. + const [days, setDays] = useState(() => Array(7).fill(true)); + const [error, setError] = useState(''); + + const toggleDay = (i: number) => + setDays((d) => d.map((on, idx) => (idx === i ? !on : on))); + + const submit = () => { + if (!name.trim()) return setError('Give the shift a name.'); + if (!start || !end) return setError('Set a start and an end time.'); + if (start === end) return setError('A shift cannot start and end at the same time.'); + if (!days.some(Boolean)) return setError('Pick at least one day.'); + + setError(''); + createMut.mutate( + { + tenantid: tenantId, + locationid, + name: name.trim(), + start_time: start, + end_time: end, + weekdays: days.every(Boolean) ? '' : days.map((d) => (d ? '1' : '0')).join(''), + }, + { + onSuccess: () => { + setName(''); + setStart(''); + setEnd(''); + setDays(Array(7).fill(true)); + }, + onError: (e: unknown) => setError((e as Error)?.message || 'Could not create the shift.'), + }, + ); + }; + + const active = shifts.filter((s) => (s.status || '').toLowerCase() !== 'inactive'); + + return ( + +
+
+ +
+

+ Shifts +

+

+ {loading + ? 'Loading…' + : active.length + ? `${active.length} window${active.length === 1 ? '' : 's'} — assign one to a person below.` + : 'None yet. Add one before you can assign it to a cashier.'} +

+
+
+ { setOpen(!open); setError(''); }}> + + Add shift + +
+ + {open && ( +
+
+ setName(e.target.value)} + placeholder="Name — e.g. Morning" + className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white" + style={{ border: `1px solid ${BORDER}`, color: TEXT }} + /> + setStart(e.target.value)} + className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white" + style={{ border: `1px solid ${BORDER}`, color: TEXT }} + /> + setEnd(e.target.value)} + className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white" + style={{ border: `1px solid ${BORDER}`, color: TEXT }} + /> +
+ +
+ + Days + + {WEEKDAY_LABELS.map((d, i) => ( + + ))} +
+ +

+ An end before the start is fine — a night shift running 22:00 to 06:00 wraps midnight. +

+ + {error &&

{error}

} + +
+ + +
+
+ )} + + {shifts.length > 0 && ( +
+ {shifts.map((s) => { + const inactive = (s.status || '').toLowerCase() === 'inactive'; + return ( +
+
+
+ {s.name} + + {s.start_time}–{s.end_time} + +
+
+ {!s.weekdays || s.weekdays === '1111111' + ? 'Every day' + : WEEKDAY_LABELS.filter((_, i) => s.weekdays?.[i] === '1').join(' ') || '—'} +
+
+ +
+ ); + })} +
+ )} +
+ ); +} + function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
diff --git a/src/services/fiestaApi.ts b/src/services/fiestaApi.ts index 6e42f9e..9fa7b16 100644 --- a/src/services/fiestaApi.ts +++ b/src/services/fiestaApi.ts @@ -1701,6 +1701,11 @@ export interface PosStaffUser { role: string; pin?: string; has_password: boolean; + /** Resolved shift. Blank on accounts created before shifts existed. */ + shift_id?: number; + shift_name?: string; + shift_start?: string; + shift_end?: string; /** Present ONLY in the answer to a create or a reset. Never in a listing. */ password?: string; location_id: number; @@ -1758,9 +1763,69 @@ export interface CreatePosStaffInput { /** "supervisor" | "cashier" — from getPosRoles, not a hardcoded literal. */ role: string; pin: string; + /** + * The mobile number this person signs in at the till with. + * + * Normalised to ten digits server-side and required to be unique among a + * tenant's till accounts, because the terminal's sign-in matches on it. + */ + contactno?: string; + /** staffshifts.staff_shift_id. Omit or 0 to leave unassigned. */ + shift_id?: number; /** Optional. Omitted, the server generates one and walks past collisions. */ authname?: string; - contactno?: string; +} + +/** A working window a till account can be assigned to. */ +export interface PosStaffShift { + staff_shift_id: number; + tenantid: number; + locationid: number; + name: string; + /** 24-hour `HH:MM`. */ + start_time: string; + end_time: string; + /** Seven 0/1 characters from Monday. Empty means every day. */ + weekdays?: string; + status: string; +} + +/** GET /tenants/getstaffshifts — the windows configured at one outlet. */ +export async function getStaffShifts(opts: { + tenantid: number; + locationid: number; +}): Promise { + const json = await fiestaGet<{ details?: { shifts?: PosStaffShift[] } | PosStaffShift[] }>( + 'tenants/getstaffshifts', + { tenantid: opts.tenantid, locationid: opts.locationid }, + ); + const d = json?.details; + if (Array.isArray(d)) return d; + return d?.shifts ?? []; +} + +export async function createStaffShift(input: { + tenantid: number; + locationid: number; + name: string; + start_time: string; + end_time: string; + weekdays?: string; +}): Promise { + return fiestaSend('tenants/createstaffshift', 'POST', input); +} + +export async function updateStaffShift(input: { + tenantid: number; + locationid: number; + staff_shift_id: number; + name?: string; + start_time?: string; + end_time?: string; + weekdays?: string; + status?: string; +}): Promise { + return fiestaSend('tenants/updatestaffshift', 'PUT', input); } /** @@ -1808,6 +1873,8 @@ export async function updatePosStaff(input: { pin?: string; authname?: string; contactno?: string; + /** staffshifts.staff_shift_id. Zero or omitted leaves the shift unchanged. */ + shift_id?: number; password?: string; status?: string; }): Promise { diff --git a/src/services/fiestaQueries.ts b/src/services/fiestaQueries.ts index ac9e10b..ff0f32d 100644 --- a/src/services/fiestaQueries.ts +++ b/src/services/fiestaQueries.ts @@ -53,6 +53,9 @@ import { createPosStaff, updatePosStaff, deactivatePosStaff, + getStaffShifts, + createStaffShift, + updateStaffShift, getMasterCatalog, getProductCategories, getProductSubcategories, @@ -1121,3 +1124,30 @@ export function useDeactivatePosStaff() { }, }); } + +/** Working windows at one outlet, for the shift picker on the staff form. */ +export function useStaffShifts(tenantid: number, locationid: number) { + return useQuery({ + queryKey: ['fiesta', 'staffshifts', tenantid, locationid], + queryFn: () => getStaffShifts({ tenantid, locationid }), + enabled: Boolean(tenantid) && Boolean(locationid), + }); +} + +export function useCreateStaffShift() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: createStaffShift, + onSuccess: (_r, v) => + qc.invalidateQueries({ queryKey: ['fiesta', 'staffshifts', v.tenantid, v.locationid] }), + }); +} + +export function useUpdateStaffShift() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: updateStaffShift, + onSuccess: (_r, v) => + qc.invalidateQueries({ queryKey: ['fiesta', 'staffshifts', v.tenantid, v.locationid] }), + }); +}