shift fix

This commit is contained in:
2026-09-25 16:19:51 +05:30
parent 9314771aa8
commit 21818320a0
5 changed files with 287 additions and 88 deletions

View File

@@ -185,11 +185,19 @@ export const posUsersApi = {
*
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
*/
shifts: (tenantid: number, locationid: number) =>
/**
* The tenant's shift windows.
*
* `locationid` is optional and usually omitted. A shift belongs to the
* business, so the tenant's set is what every picker in the console should
* offer; naming a branch narrows to the tenant's plus that branch's own, for
* the outlet that genuinely runs different hours.
*/
shifts: (tenantid: number, locationid?: number) =>
api
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
tenantid,
locationid,
...(locationid ? { locationid } : {}),
})
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
@@ -207,9 +215,16 @@ export const posUsersApi = {
*/
createShift: (
tenantid: number,
locationid: number,
shift: { name: string; start_time: string; end_time: string; weekdays?: string },
) => api.post<StaffShift>(`${WEB}/tenants/createstaffshift`, { tenantid, locationid, ...shift }),
/** Only for a shop that genuinely runs different hours from the business. */
locationid?: number,
) =>
api.post<StaffShift>(`${WEB}/tenants/createstaffshift`, {
tenantid,
// Zero means the whole tenant, which is the ordinary case.
locationid: locationid ?? 0,
...shift,
}),
};
/**

View File

@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Clock, Copy, MapPin } from 'lucide-react';
import { Copy, MapPin } 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';
@@ -333,39 +333,6 @@ export function TillDrawer({
/** The credential the backend hands back on create. Shown once, never re-read. */
const [issued, setIssued] = useState<string | null>(null);
/*
* Opening a shift window, from the form that needs one.
*
* `createstaffshift` has existed on the server since till staff were built and
* nothing in the console ever called it — so `getstaffshifts` answered
* `{"shifts": []}` at every branch, and this drawer's picker offered "Any
* shift" and nothing else, for every cashier, permanently. The same gap the
* riders had: a read wired up, a write that only the database could reach.
*
* Here rather than on a settings screen because this is where it is missed.
*/
const [newShift, setNewShift] = useState({ name: '', start_time: '09:00', end_time: '17:00' });
const setShiftField = (field: keyof typeof newShift) => (value: string) =>
setNewShift((prev) => ({ ...prev, [field]: value }));
const addShift = useMutation({
mutationFn: () =>
posUsersApi.createShift(tenantid, locationid, {
name: newShift.name.trim(),
start_time: newShift.start_time.trim(),
end_time: newShift.end_time.trim(),
}),
onSuccess: async (shift) => {
setProblem(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.people.shifts(tenantid, locationid) });
// Selected straight away — somebody who just typed these hours meant to
// put this person on them.
if (shift?.staff_shift_id) setShiftId(String(shift.staff_shift_id));
},
onError: (cause) => setProblem(errorMessage(cause)),
});
const hasNoShifts = shifts.length === 0;
const save = useMutation({
mutationFn: async () => {
@@ -552,43 +519,6 @@ export function TillDrawer({
/>
</Field>
{/* The way out of the dead end. Only when the branch genuinely has none
— a shift creator on a screen that already has shifts is how you end
up with four overlapping windows nobody can tell apart. */}
{hasNoShifts ? (
<>
<Note>
No shift windows exist at {branchName || 'this branch'} yet, so there is nothing to
put anyone on. Name one and give its hours — every till account here can then use it.
</Note>
<Field
label="Name"
isRequired
description="What people call it — Morning, Evening, Weekend cover."
>
<TextField value={newShift.name} onChange={setShiftField('name')} />
</Field>
<FieldRow>
<Field label="Starts" isRequired>
<TextField value={newShift.start_time} onChange={setShiftField('start_time')} />
</Field>
<Field label="Ends" isRequired>
<TextField value={newShift.end_time} onChange={setShiftField('end_time')} />
</Field>
</FieldRow>
<DrawerButton
label={addShift.isPending ? 'Adding…' : 'Add this shift'}
icon={<Clock size={15} />}
isDisabled={
addShift.isPending ||
newShift.name.trim() === '' ||
newShift.start_time.trim() === '' ||
newShift.end_time.trim() === ''
}
onClick={() => addShift.mutate()}
/>
</>
) : null}
</Section>
<Section title="Access">
@@ -610,3 +540,108 @@ export function TillDrawer({
</Sheet>
);
}
/* ── Shift windows ───────────────────────────────────────────────────────── */
/**
* Opening a shift window for the whole business.
*
* ── Why this is tenant-wide and not per branch ──────────────────────────────
*
* A shift is a fact about how a business runs, not about one of its shops. A
* tenant working 07:00–15:00 and 15:00–23:00 works those hours everywhere it
* trades, so asking for them per outlet means entering the same two windows
* three times — and the third branch quietly ends up on 07:00–15:30 with a
* cashier filed under hours that do not exist anywhere else.
*
* So the server takes `locationid = 0` for "the whole tenant", and this never
* sends one. A shop that genuinely runs different hours is a real case and the
* column still supports it; it is not what this form is for.
*
* ── Why it lives on Users & Access ─────────────────────────────────────────
*
* It was inside the till-account drawer, which put a piece of business-wide
* setup inside a form about one cashier — and meant nobody could see the list
* without pretending to edit somebody. It is a list, so it belongs beside the
* other lists of people.
*/
export function ShiftDrawer({
tenantid,
onClose,
}: {
tenantid: number;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('17:00');
const [problem, setProblem] = useState<string | null>(null);
const save = useMutation({
mutationFn: () =>
posUsersApi.createShift(tenantid, {
name: name.trim(),
start_time: startTime.trim(),
end_time: endTime.trim(),
}),
onSuccess: async () => {
// Every picker in the console reads this list, so all of them are stale
// the moment a shift is added — including the ones open behind this sheet.
await queryClient.invalidateQueries({ queryKey: queryKeys.people.all });
onClose();
},
onError: (cause) => setProblem(errorMessage(cause)),
});
const canSave =
name.trim() !== '' && startTime.trim() !== '' && endTime.trim() !== '' && !save.isPending;
return (
<Drawer
title="Add a shift"
subtitle="Available to every branch and every account"
width={440}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Adding…' : 'Add shift'}
variant="primary"
isDisabled={!canSave}
onClick={() => save.mutate()}
/>
</>
}
>
<Note>
A shift is the window somebody works. It is informational — nothing refuses a bill rung
outside it — and every till account and rider form in this business can use it once it
exists.
</Note>
<Section title="The window">
<Field
label="Name"
isRequired
description="What people call it — Morning, Evening, Weekend cover."
>
<TextField value={name} onChange={setName} />
</Field>
<FieldRow>
<Field label="Starts" isRequired description="24-hour, as HH:MM.">
<TextField value={startTime} onChange={setStartTime} />
</Field>
<Field label="Ends" isRequired description="Before the start means it crosses midnight.">
<TextField value={endTime} onChange={setEndTime} />
</Field>
</FieldRow>
</Section>
{problem ? <Alert tone="error">{problem}</Alert> : null}
</Drawer>
);
}

View File

@@ -5,10 +5,10 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Monitor, Plus, UserCog, Users } from 'lucide-react';
import { Bike, Clock, Monitor, Plus, UserCog, Users } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import type { RiderRosterRow } from '@/api/deliveries';
import type { PosUser, StaffInfo } from '@/api/types';
import type { PosUser, StaffInfo, StaffShift } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { SectionHeader } from '@/components/SectionHeader';
@@ -21,14 +21,15 @@ import {
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { branchLabel } from '../format';
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
import { PersonDrawer, ShiftDrawer, TillDrawer } from '../PeopleDrawers';
import { weekdayLabel } from '@/api/people';
import { RiderDrawer } from '../RiderDrawer';
import { isUnplaced } from '../staffPlacement';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
type Group = 'store' | 'till' | 'rider';
type Group = 'store' | 'till' | 'rider' | 'shift';
/**
* Users & access — three account systems, side by side and never merged.
@@ -58,13 +59,14 @@ export function UsersPage() {
null,
);
const [editingRider, setEditingRider] = useState<RiderRosterRow | 'new' | null>(null);
const [isAddingShift, setAddingShift] = useState(false);
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const staff = useStaff(tenantid || undefined);
const posUserPages = usePosUsersByBranch(tenantid || undefined, branchIds);
const roles = usePosRoles();
const shifts = useStaffShifts(tenantid || undefined, scoped[0]?.locationid);
const shifts = useStaffShifts(tenantid || undefined);
/**
* The rider directory.
@@ -151,7 +153,9 @@ export function UsersPage() {
? 'Add person'
: group === 'rider'
? 'Add rider'
: 'Add terminal account'
: group === 'shift'
? 'Add shift'
: 'Add terminal account'
}
variant="primary"
size="sm"
@@ -159,6 +163,7 @@ export function UsersPage() {
onClick={() => {
if (group === 'store') setEditingPerson('new');
else if (group === 'rider') setEditingRider('new');
else if (group === 'shift') setAddingShift(true);
else {
const locationid = selected ?? scoped[0]?.locationid;
if (locationid) setEditingTill({ row: 'new', locationid });
@@ -203,6 +208,21 @@ export function UsersPage() {
setRoleFilter('');
}}
/>
{/* Not a fourth kind of account — a list the other three read from.
It sits here because it is business-wide setup that every one of
them offers, and because it used to be buried inside the till
drawer, where nobody could see the list without pretending to
edit a cashier. */}
<Tab
label="Shifts"
icon={<Clock size={14} />}
count={shifts.data?.length || undefined}
isActive={group === 'shift'}
onClick={() => {
setGroup('shift');
setRoleFilter('');
}}
/>
</TabBar>
}
/>
@@ -210,12 +230,18 @@ export function UsersPage() {
<VStack gap={1.5}>
<SectionHeader
title={
group === 'store' ? 'Back office' : group === 'rider' ? 'Riders' : 'Counter staff'
group === 'store'
? 'Back office'
: group === 'rider'
? 'Riders'
: group === 'shift'
? 'Shift windows'
: 'Counter staff'
}
action={
<HStack gap={1} wrap="wrap">
{/* Riders have no role to filter by — a rider is a rider. */}
{group !== 'rider' ? (
{group !== 'rider' && group !== 'shift' ? (
<div style={{ width: 170 }}>
<Selector
label="Role"
@@ -239,7 +265,9 @@ export function UsersPage() {
}
/>
{group === 'store' ? (
{group === 'shift' ? (
<ShiftTable rows={shifts.data ?? []} isLoading={shifts.isLoading} />
) : group === 'store' ? (
<StaffTable
rows={staffRows}
isLoading={staff.isLoading}
@@ -298,6 +326,12 @@ export function UsersPage() {
onClose={() => setEditingRider(null)}
/>
) : null}
{/* No `row` and no branch: a shift is created for the business, and the
three account tabs beside this one all read the same list. */}
{isAddingShift ? (
<ShiftDrawer tenantid={tenantid} onClose={() => setAddingShift(false)} />
) : null}
</VStack>
);
}
@@ -749,3 +783,106 @@ function Td({
</td>
);
}
/**
* The tenant's shift windows.
*
* Read-only here. A shift is created from the header button and is used by the
* three tabs beside this one — it is not a fourth kind of account, it is the
* list they all pick from, which is why it earns a tab rather than a settings
* page nobody would find.
*/
function ShiftTable({ rows, isLoading }: { rows: StaffShift[]; isLoading: boolean }) {
const paged = usePaged(rows);
if (isLoading) {
return (
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading shifts…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<Clock size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
No shifts yet
</Text>
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 420 }}>
A shift is the window somebody works. Add one and every till account and rider in this
business can be put on it — you do not set them up per branch.
</Text>
</VStack>
</Card>
);
}
return (
<Panel
title="Shift windows"
count={rows.length}
countLabel="shifts"
footer={<TablePager paged={paged} label="shifts" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Shift</Th>
<Th>Hours</Th>
<Th>Days</Th>
<Th>Applies to</Th>
<Th>Status</Th>
</tr>
</thead>
<tbody>
{paged.rows.map((shift) => (
<tr key={shift.staff_shift_id}>
<Td>
<strong style={{ display: 'block' }}>
{shift.name?.trim() || `Shift ${shift.staff_shift_id}`}
</strong>
</Td>
<Td>
<strong style={{ display: 'block', fontVariantNumeric: 'tabular-nums' }}>
{shift.start_time} – {shift.end_time}
</strong>
{/* A window whose end is before its start runs overnight. Said
here because the two times alone read like a typo. */}
{shift.end_time && shift.start_time && shift.end_time < shift.start_time ? (
<span style={{ fontSize: 11.5, color: 'var(--color-ink-4)' }}>
Crosses midnight
</span>
) : null}
</Td>
<Td isMuted>{weekdayLabel(shift.weekdays)}</Td>
<Td isMuted>
{/* Zero means the whole business, which is the ordinary case
and the reason this list is not per branch. */}
{shift.locationid ? 'One branch' : 'Every branch'}
</Td>
<Td>
<Chip
label={shift.status || 'Active'}
tone={
(shift.status ?? 'Active').toLowerCase() === 'active' ? 'success' : 'warning'
}
/>
</Td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
);
}

View File

@@ -89,7 +89,7 @@ export function StoreStaffPage() {
// Inactive included on purpose — see the note above.
const pages = usePosUsersByBranch(tenantid || undefined, locationid ? [locationid] : [], true);
const roles = usePosRoles();
const shifts = useStaffShifts(tenantid || undefined, locationid);
const shifts = useStaffShifts(tenantid || undefined);
const page = pages[0];

View File

@@ -637,11 +637,23 @@ export function usePosRoles() {
});
}
export function useStaffShifts(tenantid: number | undefined, locationid: number | undefined) {
/**
* The shift windows this tenant's staff can be put on.
*
* Tenant-scoped, and the branch is optional. A shift is a fact about how a
* business runs — a tenant working 07:00–15:00 works those hours at every shop
* it owns — so requiring an outlet meant re-entering the same two windows per
* branch, which is how the third branch quietly ends up on 07:00–15:30.
*
* Passing a branch narrows the list to the tenant's shifts plus that branch's
* own, for the shops that genuinely differ. Omitting it returns everything the
* tenant has, which is what the pickers want.
*/
export function useStaffShifts(tenantid: number | undefined, locationid?: number) {
return useQuery({
queryKey: queryKeys.people.shifts(tenantid ?? 0, locationid ?? 0),
queryFn: () => posUsersApi.shifts(tenantid as number, locationid as number),
enabled: Boolean(tenantid) && Boolean(locationid),
queryFn: () => posUsersApi.shifts(tenantid as number, locationid),
enabled: Boolean(tenantid),
...stable,
});
}