From baab0cf6a5941daa24727e4e22b4f54c414fe854 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:06:14 +0000 Subject: [PATCH 01/12] Open shifts: a doctor can take back a request still waiting for approval A new roster_open_withdraw function lets only the doctor who asked put a waiting open-shift request back on the list. An approved, cancelled or expired request is refused with roster_conflict; anyone else's request reads as missing. The advert page offers "Take back my request" with a confirm sheet. roster_command is unchanged; no table or data changes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY --- scripts/lib/cross-tenant-roster-probe.ts | 2 +- .../open-shifts/open-shifts-advert-page.tsx | 124 +++++++++++++++++- src/lib/roster/team/demo-team-core.ts | 1 + src/lib/roster/team/model.ts | 2 + src/lib/roster/team/repository.ts | 27 ++-- src/lib/supabase/database.types.ts | 4 + supabase/applied-migration-hashes.json | 5 +- supabase/drift-manifest.json | 10 +- .../20261012090000_roster_open_withdraw.sql | 42 ++++++ supabase/schema.sql | 33 +++++ tests/open-shifts-withdraw.dom.test.tsx | 103 +++++++++++++++ tests/roster-team-api.test.ts | 16 +++ tests/sql/roster-behaviour.sql | 15 +++ 13 files changed, 366 insertions(+), 18 deletions(-) create mode 100644 supabase/migrations/20261012090000_roster_open_withdraw.sql create mode 100644 tests/open-shifts-withdraw.dom.test.tsx diff --git a/scripts/lib/cross-tenant-roster-probe.ts b/scripts/lib/cross-tenant-roster-probe.ts index fceb85d098..4abea3f067 100644 --- a/scripts/lib/cross-tenant-roster-probe.ts +++ b/scripts/lib/cross-tenant-roster-probe.ts @@ -35,7 +35,7 @@ function actionSamples(id: string, userId: string, date: string): RosterAction[] })), { action: "open.post", assignmentId: id }, { action: "open.report", assignmentId: id }, - ...(["open.claim", "open.approve", "open.decline", "open.cancel"] as const).map((action) => ({ + ...(["open.claim", "open.approve", "open.decline", "open.cancel", "open.withdraw"] as const).map((action) => ({ action, openShiftId: id, })), diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx index 1e07a875e8..34226b42e8 100644 --- a/src/components/open-shifts/open-shifts-advert-page.tsx +++ b/src/components/open-shifts/open-shifts-advert-page.tsx @@ -248,6 +248,85 @@ export function RequestSheet({ ); } +/** + * Taking back a request that still waits for approval (owner decision 12 Oct 2026). The shift goes + * straight back on the list; an approved shift is on the roster, so it is given away through Roster. + */ +function WithdrawSheet({ + listing, + open, + onClose, + onWithdrawn, +}: { + listing: OpenShiftListing; + open: boolean; + onClose: () => void; + onWithdrawn: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [wasOpen, setWasOpen] = useState(open); + if (wasOpen !== open) { + setWasOpen(open); + if (open) setError(null); + } + async function withdraw() { + setBusy(true); + setError(null); + const result = await postRosterAction(listing.serviceId, { + action: "open.withdraw", + openShiftId: listing.id, + } as RosterAction); + setBusy(false); + if (!result.ok) { + setError( + result.code === "roster_conflict" + ? "Your roster manager decided before it could be taken back. Check My shifts." + : result.code === "roster_not_found" + ? "This request can't be found any more. Check My shifts." + : `Not taken back. ${result.message}`, + ); + return; + } + onWithdrawn(); + } + return ( + + + + + } + > +
+
+

{shiftTitle(listing)}

+

{`${formatDayLong(perthDateOf(listing.startsAt))}, ${formatShiftTimes(listing.startsAt, listing.endsAt)}`}

+

{listing.siteName ?? listing.teamName}

+
+

+ The shift goes back on the list for everyone, and your roster manager no longer needs to decide. You can ask + again while it's still open. +

+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} + export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: string; openShiftId: string }) { const state = useOpenShifts(); const signedOut = useSignedOut(); @@ -256,6 +335,10 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st // Kept mounted after the first open so closing hands focus back to "Request this shift". const [sheetLoaded, setSheetLoaded] = useState(false); const [sent, setSent] = useState(null); + const [withdrawOpen, setWithdrawOpen] = useState(false); + const [withdrawLoaded, setWithdrawLoaded] = useState(false); + // Set once a waiting request is taken back, so the page drops it before the reload lands. + const [withdrawn, setWithdrawn] = useState(false); const listing = state.listings.find((row) => row.serviceId === serviceId && row.id === openShiftId) ?? null; if (state.status === "loading") { @@ -304,14 +387,16 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st const timeText = endsNextDay(listing.startsAt, listing.endsAt) ? `${perthTimeOf(listing.startsAt)}–${perthTimeOf(listing.endsAt)} next day` : formatShiftTimes(listing.startsAt, listing.endsAt); - const alreadyMine = listing.claimedByMe || sent !== null; + const alreadyMine = (listing.claimedByMe && !withdrawn) || sent !== null; + const approved = sent === "approved" || listing.status === "approved"; + const canWithdraw = alreadyMine && !approved && state.sample === null && !state.offline; const sample = state.sample !== null; // A deep link or a list read earlier can still name a shift nobody can ask for now. const closed: string | null = alreadyMine ? listing.status === "cancelled" && sent === null ? "cancelled" : null - : isBrowsable(listing, now) + : isBrowsable(listing, now) || (withdrawn && listing.claimedByMe && Date.parse(listing.startsAt) > now.getTime()) ? null : listing.mine ? "own" @@ -393,6 +478,11 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st

+ {withdrawn && !alreadyMine ? ( +

+ Request taken back. The shift is open again. +

+ ) : null} {signedOut ? ( ) : closed ? ( @@ -446,7 +536,7 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st role="status" className="flex items-start gap-3 rounded-lg border border-[color:var(--border)] px-4 py-3" > - {sent === "approved" || listing.status === "approved" ? ( + {approved ? (