shifts updated for pos

This commit is contained in:
2026-08-11 12:00:11 +05:30
parent 31d5535b72
commit 1861480b67
3 changed files with 415 additions and 4 deletions

View File

@@ -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<PosStaffUser | null>(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<PosStaffUser | null>(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 (
<>
<ShiftManager
tenantId={tenantId}
locationid={locationid}
shifts={shiftsQ.data ?? []}
loading={shiftsQ.isLoading}
/>
<Card className="p-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-start gap-2">
@@ -293,8 +332,35 @@ export default function PosStaffPanel({
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<input
value={phone}
onChange={(e) => 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 }}
/>
<select
value={shiftId}
onChange={(e) => setShiftId(Number(e.target.value))}
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white cursor-pointer"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
>
<option value={0}>
{shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet'}
</option>
{(shiftsQ.data ?? []).map((sh) => (
<option key={sh.staff_shift_id} value={sh.staff_shift_id}>
{sh.name} · {sh.start_time}–{sh.end_time}
</option>
))}
</select>
</div>
<p className="text-[11px] mt-2" style={{ color: TEXT_3 }}>
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.
</p>
{formError && (
@@ -345,11 +411,13 @@ export default function PosStaffPanel({
) : (
<div className="bg-white rounded-2xl border overflow-hidden shadow-sm w-full min-w-0" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto">
<table className="w-full text-sm whitespace-nowrap" style={{ minWidth: 720 }}>
<table className="w-full text-sm whitespace-nowrap" style={{ minWidth: 940 }}>
<thead style={TH_STYLE}>
<tr>
<th className="px-4 py-3 text-left">Name</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left">Mobile</th>
<th className="px-4 py-3 text-left">Shift</th>
<th className="px-4 py-3 text-left">Username</th>
<th className="px-4 py-3 text-left">PIN</th>
<th className="px-4 py-3 text-left">Password</th>
@@ -369,6 +437,12 @@ export default function PosStaffPanel({
color={s.role_id === POS_ROLE_CASHIER ? BRAND : '#0ea5e9'}
/>
</td>
<td className="px-4 py-3 font-mono text-[12px]" style={{ color: s.contactno ? TEXT : '#f59e0b' }}>
{s.contactno || 'not set'}
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: TEXT_2 }}>
{s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'}
</td>
<td className="px-4 py-3 font-mono text-[12px]" style={{ color: TEXT_2 }}>{s.authname || '—'}</td>
<td className="px-4 py-3 font-mono tracking-widest" style={{ color: TEXT_2 }}>{s.pin || '—'}</td>
<td className="px-4 py-3">
@@ -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 }}
/>
<input
value={editPhone}
onChange={(e) => 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 }}
/>
<select
value={editShift}
onChange={(e) => setEditShift(Number(e.target.value))}
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none cursor-pointer"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
>
<option value={0}>No shift assigned</option>
{(shiftsQ.data ?? []).map((sh) => (
<option key={sh.staff_shift_id} value={sh.staff_shift_id}>
{sh.name} · {sh.start_time}–{sh.end_time}
</option>
))}
</select>
</div>
{editError && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{editError}</p>}
<div className="flex flex-wrap items-center gap-2 mt-4">
@@ -519,6 +614,7 @@ export default function PosStaffPanel({
<div className="rounded-xl p-3 space-y-2" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<Field label="Name" value={issued.full_name} />
<Field label="Role" value={issued.role} />
<Field label="Mobile (sign-in)" value={issued.contactno || '—'} mono />
<Field label="Username" value={issued.authname || '—'} mono />
<Field label="Password" value={issued.password || '—'} mono />
<Field label="PIN" value={issued.pin || '—'} mono />
@@ -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<boolean[]>(() => 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 (
<Card className="p-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-start gap-2">
<Clock size={14} style={{ color: TEXT_2 }} className="mt-0.5" />
<div>
<h3 className="text-[13px] font-extrabold leading-tight" style={{ color: TEXT }}>
Shifts
</h3>
<p className="text-[11px] mt-0.5" style={{ color: TEXT_3 }}>
{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.'}
</p>
</div>
</div>
<Pill active={open} color={BRAND} onClick={() => { setOpen(!open); setError(''); }}>
<Plus size={12} />
Add shift
</Pill>
</div>
{open && (
<div className="mt-4 rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input
value={name}
onChange={(e) => 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 }}
/>
<input
type="time"
value={start}
onChange={(e) => setStart(e.target.value)}
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
/>
<input
type="time"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
/>
</div>
<div className="flex items-center gap-1.5 mt-3 flex-wrap">
<span className="text-[11px] font-extrabold uppercase tracking-wide mr-1" style={{ color: TEXT_3 }}>
Days
</span>
{WEEKDAY_LABELS.map((d, i) => (
<button
key={i}
onClick={() => toggleDay(i)}
className="rounded-full text-[11px] font-extrabold cursor-pointer"
style={{
width: 26,
height: 26,
background: days[i] ? BRAND : '#fff',
color: days[i] ? '#fff' : TEXT_3,
border: `1px solid ${days[i] ? BRAND : BORDER}`,
}}
>
{d}
</button>
))}
</div>
<p className="text-[11px] mt-2" style={{ color: TEXT_3 }}>
An end before the start is fine — a night shift running 22:00 to 06:00 wraps midnight.
</p>
{error && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex flex-wrap items-center gap-2 mt-3">
<button
onClick={submit}
disabled={createMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
style={{ background: BRAND }}
>
{createMut.isPending ? 'Adding…' : 'Add shift'}
</button>
<button
onClick={() => { setOpen(false); setError(''); }}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
</div>
</div>
)}
{shifts.length > 0 && (
<div className="mt-3 divide-y" style={{ borderColor: DIVIDER }}>
{shifts.map((s) => {
const inactive = (s.status || '').toLowerCase() === 'inactive';
return (
<div
key={s.staff_shift_id}
className="flex items-center justify-between gap-3 py-2"
style={{ opacity: inactive ? 0.5 : 1 }}
>
<div className="min-w-0">
<div className="text-[12.5px] font-bold" style={{ color: TEXT }}>
{s.name}
<span className="font-mono font-normal ml-2" style={{ color: TEXT_2 }}>
{s.start_time}–{s.end_time}
</span>
</div>
<div className="text-[11px]" style={{ color: TEXT_3 }}>
{!s.weekdays || s.weekdays === '1111111'
? 'Every day'
: WEEKDAY_LABELS.filter((_, i) => s.weekdays?.[i] === '1').join(' ') || '—'}
</div>
</div>
<button
onClick={() =>
updateMut.mutate({
tenantid: tenantId,
locationid,
staff_shift_id: s.staff_shift_id,
status: inactive ? 'Active' : 'Inactive',
})
}
disabled={updateMut.isPending}
className="rounded-full px-3 py-1 text-[11.5px] font-bold cursor-pointer shrink-0 disabled:opacity-50"
style={
inactive
? { background: tint('#10b981'), color: '#10b981', border: `1px solid ${edge('#10b981')}` }
: { background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }
}
>
{inactive ? 'Reactivate' : 'Retire'}
</button>
</div>
);
})}
</div>
)}
</Card>
);
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-3 text-[12.5px]">

View File

@@ -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<PosStaffShift[]> {
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<Row> {
return fiestaSend<Row>('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<Row> {
return fiestaSend<Row>('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<Row> {

View File

@@ -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] }),
});
}