From 69a846a623b2f43e84632e9fe4186ce0399a10f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:48:30 +0000 Subject: [PATCH 01/21] Open shifts: fold into Roster tabs, add requests strip, Fits your roster and list view Open shifts becomes Roster's second tab (Team moves to More), the desktop rail loses its Shifts slot, and the page leads with your requests and hours, then the next shifts that fit your roster with a Request button. Phones get a day-by-day list with the calendar one tap away; from 1024px they sit side by side. The browse window widens to four weeks. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY --- .../shift/[serviceId]/[openShiftId]/page.tsx | 7 +- .../open-shifts/open-shifts-advert-page.tsx | 25 +- .../open-shifts/open-shifts-browse-page.tsx | 587 +++++++++++------- .../open-shifts/open-shifts-for-you.tsx | 152 +++++ src/lib/open-shifts/model.ts | 4 +- src/lib/work-frame/areas.ts | 51 +- src/lib/work-frame/side-nav.ts | 1 - 7 files changed, 568 insertions(+), 259 deletions(-) create mode 100644 src/components/open-shifts/open-shifts-for-you.tsx diff --git a/src/app/(search-app)/open-shifts/shift/[serviceId]/[openShiftId]/page.tsx b/src/app/(search-app)/open-shifts/shift/[serviceId]/[openShiftId]/page.tsx index 402559aa61..d87b16a453 100644 --- a/src/app/(search-app)/open-shifts/shift/[serviceId]/[openShiftId]/page.tsx +++ b/src/app/(search-app)/open-shifts/shift/[serviceId]/[openShiftId]/page.tsx @@ -10,9 +10,10 @@ export const metadata: Metadata = { type OpenShiftsAdvertRouteProps = { params: Promise<{ serviceId: string; openShiftId: string }>; + searchParams: Promise<{ request?: string | string[] }>; }; -export default async function OpenShiftsAdvertRoute({ params }: OpenShiftsAdvertRouteProps) { - const { serviceId, openShiftId } = await params; - return ; +export default async function OpenShiftsAdvertRoute({ params, searchParams }: OpenShiftsAdvertRouteProps) { + const [{ serviceId, openShiftId }, { request }] = await Promise.all([params, searchParams]); + return ; } diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx index 1e07a875e8..1db86fed5f 100644 --- a/src/components/open-shifts/open-shifts-advert-page.tsx +++ b/src/components/open-shifts/open-shifts-advert-page.tsx @@ -248,7 +248,16 @@ export function RequestSheet({ ); } -export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: string; openShiftId: string }) { +export function OpenShiftsAdvertPage({ + serviceId, + openShiftId, + requestOnOpen = false, +}: { + serviceId: string; + openShiftId: string; + /** "Request" on a Fits your roster row: open the confirm sheet straight away. Nothing is sent until confirmed. */ + requestOnOpen?: boolean; +}) { const state = useOpenShifts(); const signedOut = useSignedOut(); const now = useRosterNow(); @@ -256,6 +265,7 @@ 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 [autoOpened, setAutoOpened] = useState(false); const listing = state.listings.find((row) => row.serviceId === serviceId && row.id === openShiftId) ?? null; if (state.status === "loading") { @@ -320,6 +330,19 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st : listing.status === "cancelled" ? "withdrawn" : "taken"; + const requestable = + !closed && + !alreadyMine && + !signedOut && + !state.offline && + check.state !== "overlap" && + check.state !== "loading" && + check.state !== "unread"; + if (requestOnOpen && requestable && !autoOpened) { + setAutoOpened(true); + setSheetLoaded(true); + setSheetOpen(true); + } return (
diff --git a/src/components/open-shifts/open-shifts-browse-page.tsx b/src/components/open-shifts/open-shifts-browse-page.tsx index 92eea6203d..84fd1e26e9 100644 --- a/src/components/open-shifts/open-shifts-browse-page.tsx +++ b/src/components/open-shifts/open-shifts-browse-page.tsx @@ -15,16 +15,26 @@ import { summariseBrowse, type BrowseFilters, } from "@/lib/open-shifts/browse"; -import { TIME_OF_DAY_LABEL, gradeLabel, windowOf } from "@/lib/open-shifts/model"; +import { TIME_OF_DAY_LABEL, WINDOW_DAYS, gradeLabel, windowOf } from "@/lib/open-shifts/model"; import { rosterCoveredUntil } from "@/lib/open-shifts/roster-check"; import { perthDateOf, perthTimeOf } from "@/lib/roster/shifts/perth-time"; import { OpenShiftsCalendar } from "./open-shifts-calendar"; +import { OpenShiftsFitsYou, OpenShiftsRequestsStrip, fitsYou } from "./open-shifts-for-you"; import { clearSavedFilters, readSavedFilters, saveFilters } from "./open-shifts-saved-filters"; import { useSignedOut } from "@/components/mode-kit/use-signed-out-sample"; import { SignInAction } from "./open-shifts-sign-in"; import { WorkSignInNotice } from "@/components/mode-kit/work-sign-in-notice"; -import { FlatList, ListSkeleton, Note, ShiftRow, advertHref, formatDayLong, formatDayShort } from "./open-shifts-ui"; +import { + FlatList, + ListSkeleton, + Note, + SectionHeading, + ShiftRow, + advertHref, + formatDayLong, + formatDayShort, +} from "./open-shifts-ui"; import { LoadFailed, NoTeam, openShiftsStatus } from "./open-shifts-states"; import { useOpenShifts } from "./use-open-shifts"; import { zonedDateOf } from "@/lib/work-time/format"; @@ -74,6 +84,8 @@ export function OpenShiftsBrowsePage() { }; const [chosenDay, setChosenDay] = useState(null); const [showHidden, setShowHidden] = useState(false); + // A list on a phone, the calendar one tap away (12 Oct 2026). From 1024px both show side by side. + const [view, setView] = useState<"list" | "calendar">("list"); // The made-up examples are filtered like real ones, against the made-up example roster in the sample data. const summary = useMemo( @@ -124,254 +136,302 @@ export function OpenShiftsBrowsePage() { ) : ( <> -
+
+
+ + +
+
+ + + {summary.sites.length > 1 ? ( + + ) : null} + + +
+
+ + + {`${partial ? "At least " : ""}${summary.matching.length} of ${summary.total}`} + {" "} + {`open shifts match · next ${WINDOW_DAYS} days`} + + {filterCount ? ( + + ) : null} +
+
+
+ {(["list", "calendar"] as const).map((choice) => ( + + ))} +
+
+ +
+
- - - {summary.sites.length > 1 ? ( - + Your roster couldn't be read, so clashes aren't checked or + hidden.{" "} + + ) : null} - - -
-
- - - {`${partial ? "At least " : ""}${summary.matching.length} of ${summary.total}`} - {" "} - open shifts match · next 14 days - - {filterCount ? ( - + {state.rosterStatus === "ready" && (!state.roster || state.roster.length === 0) && !sample ? ( + + ) : coveredUntil && coveredUntil < windowEnd && !sample ? ( + + ) : null} + {state.offline && state.readAt ? ( + ) : null} -
-
- - { - setChosenDay(date); - setShowHidden(false); - }} - perDay={summary.perDay} - urgentDays={summary.urgentDays} - rosteredDays={rosteredDays} - /> -
    -
  • - - Shifts that match -
  • -
  • - - Includes an urgent shift -
  • - {state.roster && state.roster.length > 0 ? ( -
  • -
  • - ) : null} -
- {state.rosterStatus === "error" && !sample ? ( - - ) : null} - {state.rosterStatus === "ready" && (!state.roster || state.roster.length === 0) && !sample ? ( - - ) : coveredUntil && coveredUntil < windowEnd && !sample ? ( - - ) : null} - {state.offline && state.readAt ? ( - - ) : null} + {/* The chosen day. From 1024px the list beside the calendar already shows every day. */} +
+
+

+ {formatDayLong(selected)} +

+ + {`${partial ? "at least " : ""}${day.shown.length} match`} + +
-
-

{formatDayLong(selected)}

- - {`${partial ? "at least " : ""}${day.shown.length} match`} - -
- - {day.shown.length > 0 ? ( - - {day.shown.map((row) => ( - - ))} - - ) : ( -
-
- )} + {day.shown.length > 0 ? ( + + {day.shown.map((row) => ( + + ))} + + ) : ( +
+
+ )} - {day.hidden.length > 0 ? ( - <> -
- - {`${day.hidden.length} hidden: ${[ - day.hiddenClash - ? `${day.hiddenClash} ${day.hiddenClash === 1 ? "overlaps" : "overlap"} your roster` - : null, - day.hiddenLevel - ? `${day.hiddenLevel} ${day.hiddenLevel === 1 ? "is" : "are"} for a lower level` - : null, - day.hidden.length - day.hiddenClash - day.hiddenLevel > 0 - ? `${day.hidden.length - day.hiddenClash - day.hiddenLevel} outside your site or time choices` - : null, - ] - .filter(Boolean) - .join(", ")}`} - - + {day.hidden.length > 0 ? ( + <> +
+ + {`${day.hidden.length} hidden: ${[ + day.hiddenClash + ? `${day.hiddenClash} ${day.hiddenClash === 1 ? "overlaps" : "overlap"} your roster` + : null, + day.hiddenLevel + ? `${day.hiddenLevel} ${day.hiddenLevel === 1 ? "is" : "are"} for a lower level` + : null, + day.hidden.length - day.hiddenClash - day.hiddenLevel > 0 + ? `${day.hidden.length - day.hiddenClash - day.hiddenLevel} outside your site or time choices` + : null, + ] + .filter(Boolean) + .join(", ")}`} + + +
+ {showHidden ? ( + + {day.hidden.map((row) => ( + + ))} + + ) : null} + + ) : null}
- {showHidden ? ( - - {day.hidden.map((row) => ( - - ))} - - ) : null} - - ) : null} +
+
{signedOut ? : null} @@ -390,3 +450,50 @@ export function OpenShiftsBrowsePage() {
); } + +/** Every matching shift, day by day: the phone's first view and the desktop's left column. */ +/** Days shown before "Show later days", so a phone list stays short. */ +const FIRST_DAYS = 5; + +function OpenShiftsDayList({ summary }: { readonly summary: ReturnType }) { + const [all, setAll] = useState(false); + const allDays = [...summary.perDay.keys()].sort(); + // Later days stay in the page on a computer, where the calendar scrolls to them; a phone shows them on request. + const later = all ? 0 : Math.max(0, allDays.length - FIRST_DAYS); + if (allDays.length === 0) { + return ( +

No open shifts match your filters.

+ ); + } + return ( + <> + {allDays.map((date, index) => { + const { shown } = dayRows(summary, date); + return ( +
= FIRST_DAYS ? "max-[1023px]:hidden" : ""}`} + > + {formatDayLong(date)} + + {shown.map((row) => ( + + ))} + +
+ ); + })} + {later > 0 ? ( + + ) : null} + + ); +} diff --git a/src/components/open-shifts/open-shifts-for-you.tsx b/src/components/open-shifts/open-shifts-for-you.tsx new file mode 100644 index 0000000000..dbc0f35ced --- /dev/null +++ b/src/components/open-shifts/open-shifts-for-you.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { ChevronRight, Clock, Send } from "lucide-react"; +import Link from "next/link"; +import { useMemo } from "react"; + +import { modeInsetHairline, modePressable } from "@/components/mode-kit/recipes"; +import { WorkCard, WorkIconRow } from "@/components/mode-kit/work"; +import type { BrowseRow } from "@/lib/open-shifts/browse"; +import { groupMine, hoursMeter } from "@/lib/open-shifts/mine"; +import { formatHours, hoursBetween, type OpenShiftListing } from "@/lib/open-shifts/model"; +import type { FatigueShift } from "@/lib/roster/fatigue-rules"; +import { perthDateOf, perthTimeOf } from "@/lib/roster/shifts/perth-time"; + +import { + FlatList, + SectionHeading, + advertHref, + formatDayShort, + formatWeekday, + shiftPlace, + shiftTitle, +} from "./open-shifts-ui"; + +/** + * The top of Open shifts once it became a Roster tab (12 Oct 2026): your + * requests and hours in one strip, then the next shifts that fit your roster. + * The full list of requests stays one tap away at /open-shifts/mine. + */ + +const MINE_HREF = "/open-shifts/mine"; + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +export function OpenShiftsRequestsStrip({ + listings, + roster, + now, +}: { + readonly listings: readonly OpenShiftListing[]; + readonly roster: readonly FatigueShift[] | null; + readonly now: Date; +}) { + const groups = useMemo(() => groupMine(listings, now), [listings, now]); + const meter = useMemo(() => (roster ? hoursMeter(roster, listings, now) : null), [roster, listings, now]); + const waiting = groups.requested.length; + const booked = groups.booked.length; + const changed = groups.cancelled.length; + const requestsLine = + waiting + booked + changed === 0 + ? "Nothing asked for yet" + : [ + waiting ? `${waiting} waiting` : null, + booked ? `${booked} booked` : null, + changed ? plural(changed, "changed", "changed") : null, + ] + .filter(Boolean) + .join(" · "); + return ( +
+ + + {meter ? ( + + ) : null} + +
+ ); +} + +/** The next shifts with no clash and nothing flagged on your roster, soonest first. */ +export function fitsYou(rows: readonly BrowseRow[], limit = 3): readonly BrowseRow[] { + return rows + .filter((row) => row.check.state === "ok") + .slice() + .sort((a, b) => Date.parse(a.listing.startsAt) - Date.parse(b.listing.startsAt)) + .slice(0, limit); +} + +export function OpenShiftsFitsYou({ + rows, + requestable, +}: { + readonly rows: readonly BrowseRow[]; + readonly requestable: boolean; +}) { + if (rows.length === 0) return null; + return ( +
+ Fits your roster + + {rows.map((row) => ( + + ))} + +
+ ); +} + +function FitRow({ listing, requestable }: { readonly listing: OpenShiftListing; readonly requestable: boolean }) { + const href = advertHref(listing); + const date = perthDateOf(listing.startsAt); + const weekday = formatWeekday(date); + const day = Number(date.slice(8, 10)); + return ( +
  • + + + + {weekday} + + {day} + + + + {shiftTitle(listing)} + + + {`${perthTimeOf(listing.startsAt)} to ${perthTimeOf(listing.endsAt)} · ${formatHours(hoursBetween(listing.startsAt, listing.endsAt))}`} + + {shiftPlace(listing)} + + {requestable ? null : ( +
  • + ); +} diff --git a/src/lib/open-shifts/model.ts b/src/lib/open-shifts/model.ts index a4ec755d49..468ca7320d 100644 --- a/src/lib/open-shifts/model.ts +++ b/src/lib/open-shifts/model.ts @@ -36,8 +36,8 @@ export function timeOfDay(startsAt: string): TimeOfDay { return "night"; } -/** The browse window: today and the 13 days after, as Perth dates. */ -export const WINDOW_DAYS = 14; +/** The browse window: today and the 27 days after, as Perth dates (four weeks since 12 Oct 2026). */ +export const WINDOW_DAYS = 28; export function windowOf(today: string): { start: string; end: string } { return { start: today, end: addDaysToDate(today, WINDOW_DAYS - 1) }; diff --git a/src/lib/work-frame/areas.ts b/src/lib/work-frame/areas.ts index 8bac81a577..d075d6a22f 100644 --- a/src/lib/work-frame/areas.ts +++ b/src/lib/work-frame/areas.ts @@ -358,13 +358,25 @@ const roster: WorkArea = { identity: "roster", tabs: [ { id: "month", label: "Month", sub: "Your calendar", icon: "calendar", href: "/roster", title: "Roster" }, - { id: "team", label: "Team", sub: "Who is on", icon: "users", href: "/roster/team" }, + // Open shifts folded into Roster (Work mode simplify plan, owner yes 12 Oct 2026): one page with + // your requests and hours at the top, so Requests and Alerts are no longer tabs of their own. + { + id: "open-shifts", + label: "Open shifts", + sub: "Extra shifts on offer", + icon: "hand", + href: "/open-shifts", + paths: ["/open-shifts", "/open-shifts/shift/", "/open-shifts/mine", "/open-shifts/alerts"], + title: "Open shifts", + }, { id: "swaps", label: "Swaps", sub: "Ask and answer", icon: "swap", href: "/roster/swaps" }, ], groups: [ { label: "My roster", items: [ + // Team left the tabs for Open shifts (12 Oct 2026): Month's top card opens "Who is on" too. + { id: "team", label: "Team", sub: "Who is on", icon: "users", href: "/roster/team" }, // Today folded into Month (owner decision, 11 Oct 2026): its Who is on and Phone numbers buttons // sit on Month's top card, and /roster/today opens Month in this work mode. { id: "shifts", label: "My shifts", sub: "Week by week", icon: "layers", href: "/roster/shifts" }, @@ -427,17 +439,32 @@ const roster: WorkArea = { ], }, { - label: "Inside Roster", + label: "Open shifts", items: [ { - id: "open-shifts", - label: "Open shifts", - sub: "Browse, requests, alerts", - icon: "search", - href: "/open-shifts", - paths: [], - opens: "open", + id: "open-shifts-post", + label: "Post a shift", + short: "Post", + sub: "Managers", + icon: "plus", + href: "/open-shifts/post", + paths: ["/open-shifts/post", "/open-shifts/post/"], + gate: "open-shifts-poster", }, + { + id: "open-shifts-board", + label: "Week board", + sub: "Managers", + icon: "grid", + href: "/open-shifts/board", + gate: "open-shifts-poster", + band: false, + }, + ], + }, + { + label: "Inside Roster", + items: [ { id: "manage", label: "Manage team", @@ -1300,9 +1327,9 @@ export function workAreaFor(modeId: AppModeId, pathname: string): WorkArea | nul case "roster": return pathname === "/roster/manage" || pathname.startsWith("/roster/manage/") ? manageTeam : roster; case "open-shifts": - // Open shifts is an inner area of Roster, with Roster's colours. The mode - // id stays `open-shifts` for search and the registry. - return openShifts; + // Open shifts is a Roster tab (12 Oct 2026). The mode id stays `open-shifts` + // for search and the registry. + return roster; case "teaching": return ASSESSMENT_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ? assessments diff --git a/src/lib/work-frame/side-nav.ts b/src/lib/work-frame/side-nav.ts index 291a7d7593..42775aa97b 100644 --- a/src/lib/work-frame/side-nav.ts +++ b/src/lib/work-frame/side-nav.ts @@ -33,7 +33,6 @@ function entry(id: WorkAreaId, modeId: AppModeId, label: string, short = label): export const WORK_SIDE_AREAS: readonly WorkSideArea[] = [ entry("day", "my-day", "My Day"), entry("call", "on-call", "On Call"), - entry("open", "open-shifts", "Open shifts", "Shifts"), entry("rost", "roster", "Roster"), entry("teach", "teaching", "Teaching"), entry("cpd", "cme", "CPD"), From 9cdaddde480c863defa27b5bba12d85bab299e06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:15:41 +0000 Subject: [PATCH 02/21] Open shifts: share, add to calendar, Month row, manager tools and updated tests Adds Share with a colleague and Add to calendar on a shift's page, a row on Roster's Month page counting open shifts that fit your roster, posting one shift on several dates, Post again, and Who could do this for managers. Navigation, side-menu, search and back-trail tests follow the fold into Roster. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY --- .../open-shifts-advert-actions.tsx | 74 ++++++ .../open-shifts/open-shifts-advert-page.tsx | 6 + .../open-shifts/open-shifts-browse-page.tsx | 2 +- .../open-shifts/open-shifts-fit-line.tsx | 44 ++++ .../open-shifts/open-shifts-post-page.tsx | 240 +++++++++++++++--- .../open-shifts-posted-shift-page.tsx | 145 ++++++++++- src/components/roster/roster-month-page.tsx | 8 + src/lib/open-shifts/post-plan.ts | 142 +++++++++++ src/lib/open-shifts/share.ts | 54 ++++ src/lib/open-shifts/who-could.ts | 98 +++++++ src/lib/work-frame/areas.ts | 8 +- src/lib/work-help/topics/open-shifts.ts | 10 +- src/lib/work-search/pages.ts | 2 +- tests/clinical-menu-drawer.dom.test.tsx | 10 +- tests/open-shifts-logic.test.ts | 2 +- tests/open-shifts-post-plan.test.ts | 186 ++++++++++++++ tests/open-shifts-share.test.ts | 48 ++++ tests/roster-month-page.dom.test.tsx | 2 + tests/work-frame-back-trail.dom.test.tsx | 21 +- tests/work-frame-navigation.test.ts | 20 +- tests/work-frame-route.test.ts | 2 +- tests/work-side-nav.test.ts | 8 +- 22 files changed, 1059 insertions(+), 73 deletions(-) create mode 100644 src/components/open-shifts/open-shifts-advert-actions.tsx create mode 100644 src/components/open-shifts/open-shifts-fit-line.tsx create mode 100644 src/lib/open-shifts/post-plan.ts create mode 100644 src/lib/open-shifts/share.ts create mode 100644 src/lib/open-shifts/who-could.ts create mode 100644 tests/open-shifts-post-plan.test.ts create mode 100644 tests/open-shifts-share.test.ts diff --git a/src/components/open-shifts/open-shifts-advert-actions.tsx b/src/components/open-shifts/open-shifts-advert-actions.tsx new file mode 100644 index 0000000000..7e9886943b --- /dev/null +++ b/src/components/open-shifts/open-shifts-advert-actions.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { CalendarPlus, Check, Share2 } from "lucide-react"; +import { useState } from "react"; + +import { downloadTextFile } from "@/lib/admin/download-file"; +import { icsFileName, toIcs } from "@/lib/calendar/ics"; +import type { OpenShiftListing } from "@/lib/open-shifts/model"; +import { openShiftCalendarEvent, openShiftShareText } from "@/lib/open-shifts/share"; + +import { advertHref } from "./open-shifts-ui"; + +const action = + "inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-md border border-[color:var(--border-strong)] px-3 text-sm font-semibold text-[color:var(--text-heading)] focus-visible:outline-2 focus-visible:outline-[color:var(--mode-identity)]"; + +/** + * Share a shift with a colleague, and put a booked one in your phone's calendar + * (12 Oct 2026). Sharing sends only what the advert shows; the calendar file is + * made on this phone and nothing is sent anywhere. + */ +export function OpenShiftsAdvertActions({ + listing, + booked, +}: { + readonly listing: OpenShiftListing; + /** Approved for you: the calendar button shows. */ + readonly booked: boolean; +}) { + const [copied, setCopied] = useState(false); + const shareable = listing.status === "open" && !listing.claimedByMe; + if (!shareable && !booked) return null; + + async function share() { + const link = `${window.location.origin}${advertHref(listing)}`; + const text = openShiftShareText(listing, link); + if (typeof navigator.share === "function") { + // A dismissed share sheet is not an error. + await navigator.share({ title: "Open shift", text }).catch(() => {}); + return; + } + try { + await navigator.clipboard.writeText(text); + setCopied(true); + } catch { + setCopied(false); + } + } + + function addToCalendar() { + const event = openShiftCalendarEvent(listing); + downloadTextFile(toIcs([event]), icsFileName(event.title), "text/calendar;charset=utf-8"); + } + + return ( +
    + {booked ? ( + + ) : null} + {shareable ? ( + + ) : null} +
    + ); +} diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx index 1db86fed5f..7aac663d85 100644 --- a/src/components/open-shifts/open-shifts-advert-page.tsx +++ b/src/components/open-shifts/open-shifts-advert-page.tsx @@ -36,6 +36,7 @@ import { shiftTitle, startsIn, } from "./open-shifts-ui"; +import { OpenShiftsAdvertActions } from "./open-shifts-advert-actions"; import { useOpenShifts } from "./use-open-shifts"; const AGREEMENT = `clause 15 of the ${FATIGUE_RULE_SET.source.title.includes("2024") ? "2024 WA AMA agreement" : "WA AMA agreement"}`; @@ -331,6 +332,7 @@ export function OpenShiftsAdvertPage({ ? "withdrawn" : "taken"; const requestable = + !sample && !closed && !alreadyMine && !signedOut && @@ -369,6 +371,10 @@ export function OpenShiftsAdvertPage({ + {/* Example shifts are checked against the example roster, as Browse flags them; a release-held sample has none. */}
    diff --git a/src/components/open-shifts/open-shifts-browse-page.tsx b/src/components/open-shifts/open-shifts-browse-page.tsx index 84fd1e26e9..f176afed8a 100644 --- a/src/components/open-shifts/open-shifts-browse-page.tsx +++ b/src/components/open-shifts/open-shifts-browse-page.tsx @@ -477,7 +477,7 @@ function OpenShiftsDayList({ summary }: { readonly summary: ReturnType= FIRST_DAYS ? "max-[1023px]:hidden" : ""}`} > {formatDayLong(date)} - + {shown.map((row) => ( ))} diff --git a/src/components/open-shifts/open-shifts-fit-line.tsx b/src/components/open-shifts/open-shifts-fit-line.tsx new file mode 100644 index 0000000000..9dd587fe20 --- /dev/null +++ b/src/components/open-shifts/open-shifts-fit-line.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Hand } from "lucide-react"; +import { useMemo } from "react"; + +import { WorkCard, WorkIconRow } from "@/components/mode-kit/work"; +import { useRosterNow } from "@/components/roster/roster-format"; +import { DEFAULT_FILTERS, summariseBrowse } from "@/lib/open-shifts/browse"; +import { WINDOW_DAYS } from "@/lib/open-shifts/model"; + +import { fitsYou } from "./open-shifts-for-you"; +import { OPEN_SHIFTS_HREF } from "./open-shifts-ui"; +import { useOpenShifts } from "./use-open-shifts"; + +/** + * One row on Roster's Month page (12 Oct 2026): how many open shifts fit your + * roster, so you see them without opening the tab. Nothing shows when none do, + * or while the list or your roster is still being read. + */ +export function OpenShiftsFitLine() { + const state = useOpenShifts(); + const now = useRosterNow(); + const count = useMemo( + () => + state.status === "ready" + ? fitsYou( + summariseBrowse(state.listings, state.roster, DEFAULT_FILTERS, now, state.rosterStatus).matching, + Infinity, + ).length + : 0, + [state.status, state.listings, state.roster, state.rosterStatus, now], + ); + if (count === 0) return null; + return ( + + + + ); +} diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index 65dd7ca0ed..667f072c51 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -1,12 +1,26 @@ "use client"; +import { Plus, X } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useId, useState, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useRosterNow } from "@/components/roster/roster-format"; import { postRosterAction } from "@/components/roster/use-roster-team"; import { Button } from "@/components/ui/button"; import { formatHours, gapTimes, gradeLabel, hoursBetween, kindLabel } from "@/lib/open-shifts/model"; +import { + MAX_POST_DATES, + POST_LEVELS, + datesProblem, + nextExtraDate, + parsePostAgain, + planDates, + splitAttempts, + type PostAgainPrefill, + type PostAttempt, + type PostKind, + type PostLevel, +} from "@/lib/open-shifts/post-plan"; import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; import { ROSTER_OPEN_SHIFT_KINDS, type RosterAction } from "@/lib/roster/team/model"; @@ -20,14 +34,15 @@ import { SubHeader, Switch, formatDayLong, + formatDayShort, formatShiftTimes, postedShiftHref, } from "./open-shifts-ui"; import { usePostedShifts } from "./use-posted-shifts"; -type Kind = (typeof ROSTER_OPEN_SHIFT_KINDS)[number]; -const LEVELS = ["intern", "resident", "registrar", "fellow", "consultant"] as const; -type Level = (typeof LEVELS)[number]; +type Kind = PostKind; +const LEVELS = POST_LEVELS; +type Level = PostLevel; const DEFAULTS: Record = { day: { start: "08:00", end: "16:30", code: "D" }, @@ -77,8 +92,54 @@ export function OpenShiftsPostPage() { const [level, setLevel] = useState("registrar"); const [urgent, setUrgent] = useState(false); const [confirming, setConfirming] = useState(false); + // Extra dates for the same shift. Each keeps its own id so removing one never moves another's input. + const [extras, setExtras] = useState<{ id: number; date: string }[]>([]); + const nextExtraId = useRef(0); + // Posts that went live during this visit, by everything that was sent, so "Yes" after a partial + // failure sends only the dates still missing and never posts the same shift twice. + const [done, setDone] = useState>({}); + const [progress, setProgress] = useState<{ current: number; total: number } | null>(null); + const [copied, setCopied] = useState(false); + + function applyPrefill(prefill: PostAgainPrefill) { + if (prefill.serviceId) setServiceId(prefill.serviceId); + if (prefill.kind) { + setKind(prefill.kind); + setStart(DEFAULTS[prefill.kind].start); + setEnd(DEFAULTS[prefill.kind].end); + setCode(DEFAULTS[prefill.kind].code); + } + if (prefill.start && prefill.end) { + setStart(prefill.start); + setEnd(prefill.end); + } + if (prefill.code) setCode(prefill.code); + if (prefill.level !== undefined) setLevel(prefill.level); + if (prefill.siteId) setSiteId(prefill.siteId); + if (prefill.urgent !== undefined) setUrgent(prefill.urgent); + setCopied(true); + } + + useEffect(() => { + // "Post again" carries the shift in the address. Read once, on arrival, in the browser, so the server + // and first paint agree; everything is checked before it is used, and the date is left for the manager. + let prefill: PostAgainPrefill = {}; + try { + prefill = parsePostAgain(window.location.search); + } catch { + prefill = {}; + } + // eslint-disable-next-line react-hooks/set-state-in-effect + if (Object.keys(prefill).length > 0) applyPrefill(prefill); + }, []); const team = state.teams.find((row) => row.serviceId === serviceId) ?? state.teams[0] ?? null; + // A site from the address (or another team) only counts if it is one of this team's sites. + const site = team?.sites.some((row) => row.id === siteId) ? siteId : ""; + const dates = planDates( + date, + extras.map((extra) => extra.date), + ); const times = gapTimes(date, start, end); const hours = times ? hoursBetween(times.startsAt, times.endsAt) : 0; const problem = !times @@ -87,8 +148,18 @@ export function OpenShiftsPostPage() { ? "The date has passed." : !code.trim() ? "Give the shift a code." - : null; - const siteName = team?.sites.find((site) => site.id === siteId)?.name ?? null; + : datesProblem(dates, today); + const siteName = team?.sites.find((row) => row.id === site)?.name ?? null; + const signature = (on: string) => + JSON.stringify([team?.serviceId, on, start, end, code.trim(), kind, site, level, urgent]); + const alreadyPosted = dates.filter((on) => signature(on) in done); + const toPost = dates.filter((on) => !(signature(on) in done)); + + function addExtra() { + if (dates.length >= MAX_POST_DATES) return; + nextExtraId.current += 1; + setExtras([...extras, { id: nextExtraId.current, date: nextExtraDate(dates, today) }]); + } function chooseKind(next: Kind) { setKind(next); @@ -97,25 +168,52 @@ export function OpenShiftsPostPage() { setCode(DEFAULTS[next].code); } + /** + * One `open.post` per date, one after another, each the same shift. A date that fails doesn't stop the + * rest; the answer names every date that went live and every one that didn't. + */ async function post(): Promise { if (!team || !times) return "Choose a team, date and times."; - const result = await postRosterAction(team.serviceId, { - action: "open.post", - startsAt: times.startsAt, - endsAt: times.endsAt, - shiftCode: code.trim(), - kind, - siteId: siteId || null, - minGrade: level || null, - urgent, - } as RosterAction); - if (!result.ok) return `Not posted. ${result.message}`; - router.push( - result.result.openShiftId - ? postedShiftHref(team.serviceId, result.result.openShiftId) - : `${OPEN_SHIFTS_HREF}/post`, - ); - return null; + const attempts: PostAttempt[] = []; + const nowDone = { ...done }; + for (const [index, on] of toPost.entries()) { + setProgress({ current: index + 1, total: toPost.length }); + const onTimes = gapTimes(on, start, end); + if (!onTimes) { + attempts.push({ date: on, ok: false, message: "The times couldn't be worked out for this date." }); + continue; + } + const result = await postRosterAction(team.serviceId, { + action: "open.post", + startsAt: onTimes.startsAt, + endsAt: onTimes.endsAt, + shiftCode: code.trim(), + kind, + siteId: site || null, + minGrade: level || null, + urgent, + } as RosterAction); + if (result.ok) { + attempts.push({ date: on, ok: true, openShiftId: result.result.openShiftId ?? null }); + nowDone[signature(on)] = result.result.openShiftId ?? ""; + } else { + attempts.push({ date: on, ok: false, message: result.message }); + } + } + setProgress(null); + setDone(nowDone); + const { failed } = splitAttempts(attempts); + if (failed.length === 0) { + const only = dates.length === 1 ? nowDone[signature(dates[0]!)] : ""; + router.push(only ? postedShiftHref(team.serviceId, only) : `${OPEN_SHIFTS_HREF}/post`); + return null; + } + if (dates.length === 1) return `Not posted. ${failed[0]!.message}`; + const posted = dates.filter((on) => signature(on) in nowDone); + const missing = failed.map((row) => formatDayShort(row.date)).join(", "); + const reason = failed[0]!.message; + if (posted.length === 0) return `None of the ${dates.length} dates were posted. ${reason}`; + return `Posted ${posted.length} of ${dates.length}: ${posted.map(formatDayShort).join(", ")}. Not posted: ${missing}. ${reason} "Yes" now sends only the dates not posted.`; } return ( @@ -141,6 +239,12 @@ export function OpenShiftsPostPage() { if (!problem) setConfirming(true); }} > + {copied ? ( +

    + Copied from a posted shift. Choose the date, then check the rest. +

    + ) : null} + {state.teams.length > 1 ? ( + setExtras(extras.map((row) => (row.id === extra.id ? { ...row, date: event.target.value } : row))) + } + /> + +
    + ))} + {dates.length < MAX_POST_DATES ? ( + + ) : ( +

    {`Up to ${MAX_POST_DATES} dates at once.`}

    + )}