shifts updated for pos
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
|
Clock,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
POS_ROLE_CASHIER,
|
POS_ROLE_CASHIER,
|
||||||
generatePosPassword,
|
generatePosPassword,
|
||||||
type PosRoleOption,
|
type PosRoleOption,
|
||||||
|
type PosStaffShift,
|
||||||
type PosStaffUser,
|
type PosStaffUser,
|
||||||
} from '../services/fiestaApi';
|
} from '../services/fiestaApi';
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +45,10 @@ import {
|
|||||||
useDeactivatePosStaff,
|
useDeactivatePosStaff,
|
||||||
usePosRoles,
|
usePosRoles,
|
||||||
usePosStaff,
|
usePosStaff,
|
||||||
|
useCreateStaffShift,
|
||||||
|
useStaffShifts,
|
||||||
useUpdatePosStaff,
|
useUpdatePosStaff,
|
||||||
|
useUpdateStaffShift,
|
||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
@@ -89,6 +94,7 @@ export default function PosStaffPanel({
|
|||||||
}: PosStaffPanelProps) {
|
}: PosStaffPanelProps) {
|
||||||
const staffQ = usePosStaff(tenantId, locationid);
|
const staffQ = usePosStaff(tenantId, locationid);
|
||||||
const rolesQ = usePosRoles();
|
const rolesQ = usePosRoles();
|
||||||
|
const shiftsQ = useStaffShifts(tenantId, locationid);
|
||||||
const createMut = useCreatePosStaff();
|
const createMut = useCreatePosStaff();
|
||||||
const updateMut = useUpdatePosStaff();
|
const updateMut = useUpdatePosStaff();
|
||||||
const deactivateMut = useDeactivatePosStaff();
|
const deactivateMut = useDeactivatePosStaff();
|
||||||
@@ -97,6 +103,8 @@ export default function PosStaffPanel({
|
|||||||
const [fullName, setFullName] = useState('');
|
const [fullName, setFullName] = useState('');
|
||||||
const [role, setRole] = useState('');
|
const [role, setRole] = useState('');
|
||||||
const [pin, setPin] = useState('');
|
const [pin, setPin] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [shiftId, setShiftId] = useState(0);
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
const [issued, setIssued] = useState<PosStaffUser | null>(null);
|
const [issued, setIssued] = useState<PosStaffUser | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -109,6 +117,8 @@ export default function PosStaffPanel({
|
|||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editRole, setEditRole] = useState('');
|
const [editRole, setEditRole] = useState('');
|
||||||
const [editPin, setEditPin] = useState('');
|
const [editPin, setEditPin] = useState('');
|
||||||
|
const [editPhone, setEditPhone] = useState('');
|
||||||
|
const [editShift, setEditShift] = useState(0);
|
||||||
const [editError, setEditError] = useState('');
|
const [editError, setEditError] = useState('');
|
||||||
const [confirmReset, setConfirmReset] = useState<PosStaffUser | null>(null);
|
const [confirmReset, setConfirmReset] = useState<PosStaffUser | null>(null);
|
||||||
const [rowError, setRowError] = useState('');
|
const [rowError, setRowError] = useState('');
|
||||||
@@ -125,6 +135,8 @@ export default function PosStaffPanel({
|
|||||||
setFullName('');
|
setFullName('');
|
||||||
setRole('');
|
setRole('');
|
||||||
setPin('');
|
setPin('');
|
||||||
|
setPhone('');
|
||||||
|
setShiftId(0);
|
||||||
setFormError('');
|
setFormError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,10 +152,26 @@ export default function PosStaffPanel({
|
|||||||
if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
|
if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
|
||||||
return setFormError('Another active person at this outlet already uses that PIN.');
|
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('');
|
setFormError('');
|
||||||
createMut.mutate(
|
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) => {
|
onSuccess: (created) => {
|
||||||
setIssued(created);
|
setIssued(created);
|
||||||
@@ -161,6 +189,8 @@ export default function PosStaffPanel({
|
|||||||
setEditName(s.full_name || '');
|
setEditName(s.full_name || '');
|
||||||
setEditRole(s.role_id === POS_ROLE_CASHIER ? 'cashier' : 'supervisor');
|
setEditRole(s.role_id === POS_ROLE_CASHIER ? 'cashier' : 'supervisor');
|
||||||
setEditPin(s.pin || '');
|
setEditPin(s.pin || '');
|
||||||
|
setEditPhone(s.contactno || '');
|
||||||
|
setEditShift(s.shift_id || 0);
|
||||||
setEditError('');
|
setEditError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,6 +210,8 @@ export default function PosStaffPanel({
|
|||||||
// role as a change, and a no-op write still moves the row.
|
// role as a change, and a no-op write still moves the row.
|
||||||
role: editRole && editRole !== (editing.role || '').toLowerCase() ? editRole : undefined,
|
role: editRole && editRole !== (editing.role || '').toLowerCase() ? editRole : undefined,
|
||||||
pin: editPin && editPin !== editing.pin ? editPin : 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),
|
onSuccess: () => setEditing(null),
|
||||||
@@ -241,6 +273,13 @@ export default function PosStaffPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ShiftManager
|
||||||
|
tenantId={tenantId}
|
||||||
|
locationid={locationid}
|
||||||
|
shifts={shiftsQ.data ?? []}
|
||||||
|
loading={shiftsQ.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -293,8 +332,35 @@ export default function PosStaffPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 }}>
|
<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>
|
</p>
|
||||||
|
|
||||||
{formError && (
|
{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="bg-white rounded-2xl border overflow-hidden shadow-sm w-full min-w-0" style={{ borderColor: BORDER }}>
|
||||||
<div className="overflow-x-auto">
|
<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}>
|
<thead style={TH_STYLE}>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left">Name</th>
|
<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">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">Username</th>
|
||||||
<th className="px-4 py-3 text-left">PIN</th>
|
<th className="px-4 py-3 text-left">PIN</th>
|
||||||
<th className="px-4 py-3 text-left">Password</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'}
|
color={s.role_id === POS_ROLE_CASHIER ? BRAND : '#0ea5e9'}
|
||||||
/>
|
/>
|
||||||
</td>
|
</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 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 font-mono tracking-widest" style={{ color: TEXT_2 }}>{s.pin || '—'}</td>
|
||||||
<td className="px-4 py-3">
|
<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"
|
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono tracking-widest"
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
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>
|
</div>
|
||||||
{editError && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{editError}</p>}
|
{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">
|
<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}` }}>
|
<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="Name" value={issued.full_name} />
|
||||||
<Field label="Role" value={issued.role} />
|
<Field label="Role" value={issued.role} />
|
||||||
|
<Field label="Mobile (sign-in)" value={issued.contactno || '—'} mono />
|
||||||
<Field label="Username" value={issued.authname || '—'} mono />
|
<Field label="Username" value={issued.authname || '—'} mono />
|
||||||
<Field label="Password" value={issued.password || '—'} mono />
|
<Field label="Password" value={issued.password || '—'} mono />
|
||||||
<Field label="PIN" value={issued.pin || '—'} 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 }) {
|
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 text-[12.5px]">
|
<div className="flex items-center justify-between gap-3 text-[12.5px]">
|
||||||
|
|||||||
@@ -1701,6 +1701,11 @@ export interface PosStaffUser {
|
|||||||
role: string;
|
role: string;
|
||||||
pin?: string;
|
pin?: string;
|
||||||
has_password: boolean;
|
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. */
|
/** Present ONLY in the answer to a create or a reset. Never in a listing. */
|
||||||
password?: string;
|
password?: string;
|
||||||
location_id: number;
|
location_id: number;
|
||||||
@@ -1758,9 +1763,69 @@ export interface CreatePosStaffInput {
|
|||||||
/** "supervisor" | "cashier" — from getPosRoles, not a hardcoded literal. */
|
/** "supervisor" | "cashier" — from getPosRoles, not a hardcoded literal. */
|
||||||
role: string;
|
role: string;
|
||||||
pin: 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. */
|
/** Optional. Omitted, the server generates one and walks past collisions. */
|
||||||
authname?: string;
|
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;
|
pin?: string;
|
||||||
authname?: string;
|
authname?: string;
|
||||||
contactno?: string;
|
contactno?: string;
|
||||||
|
/** staffshifts.staff_shift_id. Zero or omitted leaves the shift unchanged. */
|
||||||
|
shift_id?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}): Promise<Row> {
|
}): Promise<Row> {
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import {
|
|||||||
createPosStaff,
|
createPosStaff,
|
||||||
updatePosStaff,
|
updatePosStaff,
|
||||||
deactivatePosStaff,
|
deactivatePosStaff,
|
||||||
|
getStaffShifts,
|
||||||
|
createStaffShift,
|
||||||
|
updateStaffShift,
|
||||||
getMasterCatalog,
|
getMasterCatalog,
|
||||||
getProductCategories,
|
getProductCategories,
|
||||||
getProductSubcategories,
|
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] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user