From 21818320a03850bee559a64efe91a164d2f625b8 Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 25 Sep 2026 16:19:51 +0530 Subject: [PATCH] shift fix --- src/api/people.ts | 23 ++- src/features/store-admin/PeopleDrawers.tsx | 177 +++++++++++------- src/features/store-admin/pages/UsersPage.tsx | 155 ++++++++++++++- .../store-user/pages/StoreStaffPage.tsx | 2 +- src/queries/hooks.ts | 18 +- 5 files changed, 287 insertions(+), 88 deletions(-) diff --git a/src/api/people.ts b/src/api/people.ts index 10cc24c..0a3fc3e 100644 --- a/src/api/people.ts +++ b/src/api/people.ts @@ -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(`${WEB}/tenants/createstaffshift`, { tenantid, locationid, ...shift }), + /** Only for a shop that genuinely runs different hours from the business. */ + locationid?: number, + ) => + api.post(`${WEB}/tenants/createstaffshift`, { + tenantid, + // Zero means the whole tenant, which is the ordinary case. + locationid: locationid ?? 0, + ...shift, + }), }; /** diff --git a/src/features/store-admin/PeopleDrawers.tsx b/src/features/store-admin/PeopleDrawers.tsx index e84cc14..e446d43 100644 --- a/src/features/store-admin/PeopleDrawers.tsx +++ b/src/features/store-admin/PeopleDrawers.tsx @@ -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(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({ /> - {/* 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 ? ( - <> - - 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. - - - - - - - - - - - - - } - isDisabled={ - addShift.isPending || - newShift.name.trim() === '' || - newShift.start_time.trim() === '' || - newShift.end_time.trim() === '' - } - onClick={() => addShift.mutate()} - /> - - ) : null}
@@ -610,3 +540,108 @@ export function TillDrawer({ ); } + +/* ── 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(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 ( + + + save.mutate()} + /> + + } + > + + 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. + + +
+ + + + + + + + + + + +
+ + {problem ? {problem} : null} +
+ ); +} diff --git a/src/features/store-admin/pages/UsersPage.tsx b/src/features/store-admin/pages/UsersPage.tsx index 0747255..1c500c4 100644 --- a/src/features/store-admin/pages/UsersPage.tsx +++ b/src/features/store-admin/pages/UsersPage.tsx @@ -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(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. */} + } + count={shifts.data?.length || undefined} + isActive={group === 'shift'} + onClick={() => { + setGroup('shift'); + setRoleFilter(''); + }} + /> } /> @@ -210,12 +230,18 @@ export function UsersPage() { {/* Riders have no role to filter by — a rider is a rider. */} - {group !== 'rider' ? ( + {group !== 'rider' && group !== 'shift' ? (
- {group === 'store' ? ( + {group === 'shift' ? ( + + ) : group === 'store' ? ( 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 ? ( + setAddingShift(false)} /> + ) : null} ); } @@ -749,3 +783,106 @@ function 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 ( + + + + Reading shifts… + + + + ); + } + + if (rows.length === 0) { + return ( + + + + + + + No shifts yet + + + 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. + + + + ); + } + + return ( + } + > +
+ + + + + + + + + + + + {paged.rows.map((shift) => ( + + + + + + + + ))} + +
ShiftHoursDaysApplies toStatus
+ + {shift.name?.trim() || `Shift ${shift.staff_shift_id}`} + + + + {shift.start_time} – {shift.end_time} + + {/* 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 ? ( + + Crosses midnight + + ) : null} + {weekdayLabel(shift.weekdays)} + {/* 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'} + + +
+
+
+ ); +} diff --git a/src/features/store-user/pages/StoreStaffPage.tsx b/src/features/store-user/pages/StoreStaffPage.tsx index 05e6998..9d60083 100644 --- a/src/features/store-user/pages/StoreStaffPage.tsx +++ b/src/features/store-user/pages/StoreStaffPage.tsx @@ -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]; diff --git a/src/queries/hooks.ts b/src/queries/hooks.ts index 7c91e88..18cdc16 100644 --- a/src/queries/hooks.ts +++ b/src/queries/hooks.ts @@ -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, }); }