shift fix
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user