diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index e64d4c12..b21a8ded 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/work-orders/due-date-schedule-floor.spec.ts b/e2e/work-orders/due-date-schedule-floor.spec.ts new file mode 100644 index 00000000..afd181e9 --- /dev/null +++ b/e2e/work-orders/due-date-schedule-floor.spec.ts @@ -0,0 +1,80 @@ +import { expect, test, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "due-date-floor-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +test.describe("Due Date floor in the Create WO wizard (SH-356)", () => { + test.setTimeout(60_000); + + test("disables days before Schedule On and clears Due Date when Schedule On passes it", async ({ + page, + }) => { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => { + localStorage.setItem("auth", JSON.stringify(user)); + const style = document.createElement("style"); + style.textContent = ".tsqd-parent-container { display: none !important; }"; + document.documentElement.appendChild(style); + }, TEST_USER); + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + if (route.request().method() !== "GET") { + await fulfillJson(route, {}, 500); + return; + } + await fulfillJson(route, { + data: [], + items: [], + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 0, total: 0 }, + unscheduled: [], + scheduled: [], + }); + }, + ); + + await page.goto("/workorders"); + await page.getByRole("button", { name: "New WO" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({ + timeout: 20_000, + }); + + await page.getByRole("button", { name: /Pick a date/ }).click(); + const dateTab = page.getByRole("button", { name: "Date", exact: true }); + if (await dateTab.isVisible()) { + await dateTab.click(); + } + await page.getByRole("button", { name: "20", exact: true }).click(); + + await page.getByRole("button", { name: /No due date/ }).click(); + await expect(page.getByRole("button", { name: "19", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "20", exact: true })).toBeEnabled(); + await page.getByRole("button", { name: "21", exact: true }).click(); + await expect(page.getByRole("button", { name: /No due date/ })).toHaveCount(0); + + await page.getByRole("button", { name: /Aug 20/ }).click(); + const dateTabAgain = page.getByRole("button", { name: "Date", exact: true }); + if (await dateTabAgain.isVisible()) { + await dateTabAgain.click(); + } + await page.getByRole("button", { name: "25", exact: true }).click(); + + await expect(page.getByRole("button", { name: /No due date/ })).toBeVisible(); + }); +}); diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx index adcf3e20..cf89d2bf 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx @@ -6,8 +6,8 @@ import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work- import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate"; +import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition-slide-over-media"; import { formatMediaSizeLimit, isWorkOrderFileTooLarge, @@ -17,10 +17,6 @@ import { SlideOverCompletionMedia } from "./slide-over-completion-media"; import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status"; import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs"; -function isMediaWO(type: WOType | ""): boolean { - return type === "Emergency" || type === "Reactive"; -} - type SlideOverCompletionTabProps = { row: WorkOrderTableRow; isReadOnly: boolean; @@ -57,7 +53,7 @@ export function SlideOverCompletionTab({ const templateUrl = completion?.template?.templateUrl; const signOffAttachment = completion?.signOffAttachment; - if (isMediaWO(row.type)) { + if (hasCompletionMediaGallery(row.type)) { return ( = { other: Paperclip, }; +const FILETYPE_LABEL: Record = { + image: "Photo", + video: "Video", + pdf: "PDF", + doc: "Document", + other: "File", +}; + const EXTRA_DOC_PURPOSES: Array> = [ "Extra", "Aveta", @@ -92,8 +100,13 @@ function ExtraDocRow({ const purpose = file.purpose === "Aveta" || isAveta ? "Aveta" : "Extra"; return ( -
+
onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, scheduledOn), scheduledOn, scheduleWeekOnly: false, targetWeek: scheduledOn ? "" : draft.targetWeek, @@ -63,6 +68,7 @@ function SlideOverScheduleOnField({ } onSaveWeek={(weekStart) => onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, weekStart), scheduledOn: "", targetWeek: weekStart, scheduleWeekOnly: true, @@ -74,6 +80,7 @@ function SlideOverScheduleOnField({ } onSaveRange={(start, end) => onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, start), scheduledOn: start, scheduleEnd: end, schedMode: "range", @@ -175,6 +182,7 @@ export function SlideOverInfoTabEditView({ onDraftChange({ dueDate })} /> diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 346ecb57..5bde5578 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -12,6 +12,7 @@ import type { UploadWorkOrderMediaInput, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { + hasCompletionMediaGallery, isCompletionSurfaceMedia, isExtrasSurfaceMedia, type MediaSurfaceOwnership, @@ -80,6 +81,7 @@ export function useSlideOverMediaState({ categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { const workOrderId = detailForRow?.info.id; + const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type); const [mediaBatchPending, setMediaBatchPending] = useState(false); const [ownership, setOwnershipState] = useState(() => loadOwnership(workOrderId), @@ -116,16 +118,16 @@ export function useSlideOverMediaState({ useMediaUploadSession(detailForRow, persistCompletionUpload); const completionMedia = useMemo( - () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)), - [uploads, ownership], + () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)), + [uploads, ownership, completionHasGallery], ); const extrasUploads = useMemo( () => boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) => - isExtrasSurfaceMedia(item, ownership), + isExtrasSurfaceMedia(item, ownership, completionHasGallery), ), - [detailForRow?.media, ownership], + [detailForRow?.media, ownership, completionHasGallery], ); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx index 12b5a42e..5c76c5a6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -25,6 +25,8 @@ type DateCellProps = { onClear?: () => void; /** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */ srStatusSuffix?: string; + /** Earliest selectable day in the date calendar (SH-356). */ + minDate?: string; }; export function DateCell({ @@ -41,6 +43,7 @@ export function DateCell({ onSaveRange, onClear, srStatusSuffix, + minDate, }: DateCellProps) { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -154,6 +157,7 @@ export function DateCell({ ) : ( { onSave(d); setAnchorEl(null); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx index a47b8e14..3f5d4915 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx @@ -8,6 +8,7 @@ type DueDateCalendarDialogProps = { onOpenChange: (open: boolean) => void; value: string; onSave: (iso: string) => void; + minDate?: string; }; /** @@ -19,6 +20,7 @@ export function DueDateCalendarDialog({ onOpenChange, value, onSave, + minDate, }: DueDateCalendarDialogProps) { return ( { onSave(iso); onOpenChange(false); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx index 1ce90d18..5f09b0a4 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx @@ -16,6 +16,8 @@ type DueDateCellProps = { * read by colour alone. */ pastDue?: boolean; + /** Schedule On floor: earlier days are not selectable (SH-356). */ + minDate?: string; onSave: (date: string) => void; }; @@ -29,6 +31,7 @@ export function DueDateCell({ q, highlight, pastDue = false, + minDate, onSave, }: DueDateCellProps) { const [open, setOpen] = useState(false); @@ -40,6 +43,7 @@ export function DueDateCell({ q={q} highlight={highlight} onSave={onSave} + minDate={minDate} srStatusSuffix={pastDue ? "Past due" : undefined} /> ); @@ -87,7 +91,13 @@ export function DueDateCell({ style={{ color: "var(--muted-foreground)" }} /> - + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index 369c1101..39744598 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -22,6 +22,8 @@ type EditableWONumberCellProps = { onSave: (v: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; locked?: boolean; + /** Empty-state label for create flows; renders with a persistent required asterisk. */ + requiredPlaceholder?: string; }; async function resolveDuplicateWoNumber( @@ -34,6 +36,28 @@ async function resolveDuplicateWoNumber( return remote && String(remote.id) !== String(currentId) ? remote : undefined; } +function EmptyWoNumberLabel({ + value, + q, + requiredPlaceholder, +}: { + value: string; + q?: string; + requiredPlaceholder?: string; +}) { + if (value) { + return ; + } + if (!requiredPlaceholder) { + return "-"; + } + return ( + + {requiredPlaceholder} * + + ); +} + export function EditableWONumberCell({ value, rescheduleCount, @@ -49,6 +73,7 @@ export function EditableWONumberCell({ onSave, onDuplicateFound, locked = false, + requiredPlaceholder, }: EditableWONumberCellProps) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -162,7 +187,7 @@ export function EditableWONumberCell({ padding: "2px 4px", }} > - {value ? : "-"} + )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx index 48ecbec6..bdccff61 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx @@ -7,6 +7,7 @@ type SiteDialogPocSectionProps = { pocNotes: string; additionalContacts: WorkOrderAdditionalContact[]; showPrimaryErrors?: boolean; + primaryRequired?: boolean; onPocNameChange: (value: string) => void; onPocPhoneChange: (value: string) => void; onPocNotesChange: (value: string) => void; @@ -19,6 +20,7 @@ export function SiteDialogPocSection({ pocNotes, additionalContacts, showPrimaryErrors = false, + primaryRequired = false, onPocNameChange, onPocPhoneChange, onPocNotesChange, @@ -31,6 +33,7 @@ export function SiteDialogPocSection({ pocNotes={pocNotes} additionalContacts={additionalContacts} showPrimaryErrors={showPrimaryErrors} + primaryRequired={primaryRequired} onPocNameChange={onPocNameChange} onPocPhoneChange={onPocPhoneChange} onPocNotesChange={onPocNotesChange} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index 239c883e..06c5e6bb 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -86,7 +86,7 @@ export function SiteDialog({
- Site + Site ; + +function createDragHandlers({ dragState, setDragViz, reorderRows, endDrag }: DragHandlersArgs) { + return { + onDragStart: (id: string | number) => { + dragState.current = { dragId: id, overId: null }; + setDragViz({ dragId: id, overId: null }); + }, + onDragOver: (id: string | number) => { + const did = dragState.current.dragId; + if (did && did !== id && dragState.current.overId !== id) { + dragState.current.overId = id; + setDragViz((v) => ({ ...v, overId: id })); + } + }, + onDrop: () => { + const { dragId, overId } = dragState.current; + if (dragId && overId) { + reorderRows(dragId, overId); + } + endDrag(); + }, + onDragEnd: endDrag, + }; +} + type UseWoTableRowHandlersOptions = { tableData: ReturnTypeOfUseWorkOrdersTableData; weekMonday: string; @@ -101,8 +131,11 @@ export function useWoTableRowHandlers({ onPatch: (id, patch, options) => { onPatch(id, patch, options); }, - onScheduleDate: (id: string | number, date: string) => { - onPatch(id, applySchedulePatch(date, false)); + onScheduleDate: (id: string | number, date: string, dueDate?: string) => { + onPatch(id, { + ...applySchedulePatch(date, false), + ...clearDueDateBeforeSchedule(dueDate, date), + }); revealOrNotifyScheduledDay({ id, date, @@ -111,8 +144,11 @@ export function useWoTableRowHandlers({ revealScheduledRow, }); }, - onScheduleRange: (id: string | number, start: string, end: string) => { - onPatch(id, applyScheduleRangePatch(start, end)); + onScheduleRange: (id: string | number, start: string, end: string, dueDate?: string) => { + onPatch(id, { + ...applyScheduleRangePatch(start, end), + ...clearDueDateBeforeSchedule(dueDate, start), + }); revealOrNotifyScheduledDay({ id, date: start, @@ -121,7 +157,7 @@ export function useWoTableRowHandlers({ revealScheduledRow, }); }, - onScheduleWeek: (id: string | number, week: string) => { + onScheduleWeek: (id: string | number, week: string, dueDate?: string) => { const isClear = week === ""; onPatch( id, @@ -135,6 +171,7 @@ export function useWoTableRowHandlers({ schedMode: "week", dayGroup: "", dayLabel: "", + ...clearDueDateBeforeSchedule(dueDate, week), }, ); weekMoveNotify(week, { @@ -153,25 +190,12 @@ export function useWoTableRowHandlers({ onRequestComplete, onPastDueAttempt: (id: string | number) => onScheduleHighlight(id), onReorder: reorderRows, - onDragStart: (id: string | number) => { - dragStateRef.current = { dragId: id, overId: null }; - setDragViz({ dragId: id, overId: null }); - }, - onDragOver: (id: string | number) => { - const did = dragStateRef.current.dragId; - if (did && did !== id && dragStateRef.current.overId !== id) { - dragStateRef.current.overId = id; - setDragViz((v) => ({ ...v, overId: id })); - } - }, - onDrop: () => { - const { dragId, overId } = dragStateRef.current; - if (dragId && overId) { - reorderRows(dragId, overId); - } - endDrag(); - }, - onDragEnd: endDrag, + ...createDragHandlers({ + dragState: dragStateRef, + setDragViz, + reorderRows, + endDrag, + }), }), [ tableData, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx index 47ca7b46..50915a28 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx @@ -89,6 +89,7 @@ export function WoTableInlineIdentityCells({ allOrders={allRows as never[]} onSave={(v) => onPatch({ woNumber: v })} onDuplicateFound={onDuplicateFound} + requiredPlaceholder="Insert WO #" /> +
- + ); } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx index 61bf4927..c7916686 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx @@ -26,6 +26,7 @@ export function WizardStepLocationPocFields({ onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)} helperText="Auto-filled from records — edit if outdated" showPrimaryErrors={showErrors && pocIncomplete} + primaryRequired /> ); } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index f4df71a3..400ad771 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -29,6 +29,10 @@ import { import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker"; import { CheckCircle2, Clock, Flame, Users, Wrench, Zap } from "lucide-react"; import type { ElementType } from "react"; +import { + clearDueDateBeforeSchedule, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; const WO_TYPE_ICONS: Record = { PM: Wrench, @@ -165,6 +169,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChange={(iso) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, iso), scheduledOn: iso, scheduleWeekOnly: false, scheduleEnd: "", @@ -177,6 +182,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChangeWeek={(w) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, w), scheduledOn: w, scheduleWeekOnly: Boolean(w), scheduleEnd: "", @@ -189,6 +195,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChangeRange={(s, e) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, s), scheduledOn: s, scheduleEnd: e, schedMode: "range", @@ -211,6 +218,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onUpdate("dueDate", iso)} /> diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts index 7522bb47..cbc1da83 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts @@ -33,7 +33,8 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar () => buildResolvedBoardSearchParams({ advApplied: filters.advApplied, - search: filters.search, + // Advanced Filters are global by design; the week-scoped keyword box has no effect. + search: "", page: filters.page, pageSize: filters.pageSize, sortBy: filters.sortBy, @@ -43,7 +44,6 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar }), [ filters.advApplied, - filters.search, filters.page, filters.pageSize, filters.sortBy, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 266d84e3..182a98e5 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -8,6 +8,7 @@ import { } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; import { patchWorkOrderAsCanceled, patchWorkOrderAsCompleted, @@ -112,7 +113,17 @@ export function useWorkOrdersListPage() { generatedAt: data.generatedAt, }; setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData })); - await patchFieldAsync(row.id, { docStatus: status }); + const saved = await patchFieldAsync(row.id, { docStatus: status }); + if (saved.kind !== "board" || !shouldCompleteAfterCompletionDoc(status, saved.row)) { + return; + } + try { + // Generation is the one automatic completion — no confirmation dialog. + await patchFieldAsync(row.id, { status: "Completed" }, saved.row); + } catch { + // The document is saved; the table mutation already surfaced the status error and + // refetched, so the WO stays in its prior status for a manual retry. + } }; return { diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 571251cb..e12e040d 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -43,10 +43,12 @@ export default function WorkOrdersListPage() { actions={ filters.setAdvOpen(true)} onNewWorkOrder={() => page.setWizardOpen(true)} activeFilterCount={filters.activeFilterCount} + advancedFiltersActive={filters.advApplied != null} isFetching={tableData.isFetching && !tableData.isLoading} /> } diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index 6922c367..a02950d3 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -134,11 +134,17 @@ export function useWorkOrderTableMutations( mutationFn: ({ id, persistPatch, + baseRow, }: { id: string | number; patch: WorkOrderTablePatch; persistPatch: WorkOrderTablePatch; - }) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)), + /** Server row from a just-settled patch; its rowVersion is fresher than the table snapshot. */ + baseRow?: WorkOrderTableRow; + }) => + enqueuePatch(id, () => + runWorkOrderTablePatch(id, persistPatch, baseRow ? () => baseRow : getRow), + ), onMutate: async ({ id, persistPatch }) => { onPatch(id, persistPatch); }, @@ -182,9 +188,13 @@ export function useWorkOrderTableMutations( ); }; - const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => { - const persistPatch = resolvePatch(id, patch); - return patchMutation.mutateAsync({ id, patch, persistPatch }); + const patchFieldAsync = ( + id: string | number, + patch: WorkOrderTablePatch, + baseRow?: WorkOrderTableRow, + ) => { + const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch); + return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow }); }; return { diff --git a/src/domain/work-orders/utils/completion-doc-auto-complete.ts b/src/domain/work-orders/utils/completion-doc-auto-complete.ts new file mode 100644 index 00000000..e27f0318 --- /dev/null +++ b/src/domain/work-orders/utils/completion-doc-auto-complete.ts @@ -0,0 +1,25 @@ +import type { CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + getClosabilityGaps, + tableRowToClosabilityInput, +} from "@/domain/work-orders/utils/wo-closability"; + +/** + * Completion is manual with confirmation, except that generating the completion document + * completes the WO — but only when the closability gate passes on the persisted row. + * Not-required (NN) and drafts never complete; Past Due rows must be + * rescheduled before any status change, so they are left for the dispatcher. + */ +export function shouldCompleteAfterCompletionDoc( + status: CompDocSaveStatus, + row: WorkOrderTableRow, +): boolean { + if (status !== "Yes" || row.docStatus !== "Yes") { + return false; + } + if (row.status === "Completed" || row.status === "Canceled" || row.isPastDue) { + return false; + } + return getClosabilityGaps(tableRowToClosabilityInput(row)).length === 0; +} diff --git a/src/domain/work-orders/utils/due-date-floor.ts b/src/domain/work-orders/utils/due-date-floor.ts new file mode 100644 index 00000000..95f8eb54 --- /dev/null +++ b/src/domain/work-orders/utils/due-date-floor.ts @@ -0,0 +1,27 @@ +/** + * SH-356: a Due Date can never be earlier than Schedule On. + * + * The floor is the concrete Schedule On day, the start of a date range, or — for a + * week-only schedule (SH-110) — the first day of the target week. + */ +export function resolveDueDateFloor(schedule: { + scheduledOn?: string | null; + targetWeek?: string | null; +}): string { + return schedule.scheduledOn?.trim() || schedule.targetWeek?.trim() || ""; +} + +export function isDueDateBeforeFloor(dueDate: string | null | undefined, floor: string): boolean { + return Boolean(dueDate && floor && dueDate < floor); +} + +/** + * Patch fragment for a Schedule On change: clears an existing Due Date that the new + * schedule would leave in the past. Never bumps it to a guessed value. + */ +export function clearDueDateBeforeSchedule( + dueDate: string | null | undefined, + nextFloor: string, +): { dueDate?: string } { + return isDueDateBeforeFloor(dueDate, nextFloor) ? { dueDate: "" } : {}; +} diff --git a/src/domain/work-orders/utils/partition-slide-over-media.ts b/src/domain/work-orders/utils/partition-slide-over-media.ts index 5de1e4ad..7092c696 100644 --- a/src/domain/work-orders/utils/partition-slide-over-media.ts +++ b/src/domain/work-orders/utils/partition-slide-over-media.ts @@ -12,6 +12,11 @@ const EMPTY_OWNERSHIP: MediaSurfaceOwnership = { extraOwnedIds: new Set(), }; +/** Only Reactive and Emergency work orders render a media gallery on the Completion tab. */ +export function hasCompletionMediaGallery(type: string | null | undefined): boolean { + return type === "Emergency" || type === "Reactive"; +} + function isPhotoOrVideo(item: MediaFile): boolean { return item.fileType === "image" || item.fileType === "video"; } @@ -24,20 +29,25 @@ function isInFlight(item: MediaFile): boolean { export function isCompletionSurfaceMedia( item: MediaFile, ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, + completionHasGallery = true, ): boolean { if (isInFlight(item)) return true; if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true; + if (!completionHasGallery && item.purpose === "Extra") return false; if (ownership.completionOwnedIds.has(item.id)) return true; if (ownership.extraOwnedIds.has(item.id)) return false; return item.purpose === "Extra" && isPhotoOrVideo(item); } +/** Without a Completion gallery, every Extra item (including photos/videos) belongs to Extras. */ export function isExtrasSurfaceMedia( item: MediaFile, ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, + completionHasGallery = true, ): boolean { if (item.purpose === "Aveta") return true; if (item.purpose !== "Extra") return false; + if (!completionHasGallery) return true; if (ownership.completionOwnedIds.has(item.id)) return false; if (ownership.extraOwnedIds.has(item.id)) return true; return !isPhotoOrVideo(item); diff --git a/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx new file mode 100644 index 00000000..e6c41d50 --- /dev/null +++ b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx @@ -0,0 +1,194 @@ +import { fireEvent, render, renderHook, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view"; +import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row"; +import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers"; +import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; +import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({ + SiteCell: () => null, +})); +vi.mock( + "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell", + () => ({ EditableWONumberCell: () => null }), +); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({ + TypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ + AssignedToCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify", () => ({ + rescheduleNotify: vi.fn(), + weekMoveNotify: vi.fn(), +})); + +function day(n: number): string { + const [y, m] = todayIso().split("-").map(Number); + return `${y}-${pad2(m)}-${pad2(n)}`; +} + +function rowByLabel(label: string) { + return screen.getByText(label, { selector: "span,label,div" }).closest("div")!; +} + +describe("Due Date cannot be before Schedule On (SH-356)", () => { + it("disables calendar days before the floor", () => { + render(); + + expect(screen.getByRole("button", { name: "9" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "10" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "11" })).toBeEnabled(); + }); + + describe("slide-over edit", () => { + const draft = { scheduledOn: day(10), dueDate: day(12) } as Partial; + + function renderSlideOver(onDraftChange = vi.fn()) { + renderWithProviders( + , + ); + return onDraftChange; + } + + it("disables Due Date days before Schedule On", async () => { + renderSlideOver(); + fireEvent.click(within(rowByLabel("Due Date")).getByRole("button")); + + expect(await screen.findByRole("button", { name: "9" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "10" })).toBeEnabled(); + }); + + it("clears the Due Date when Schedule On moves past it", async () => { + const onDraftChange = renderSlideOver(); + fireEvent.click(within(rowByLabel("Schedule On")).getAllByRole("button")[0]); + fireEvent.click(await screen.findByRole("button", { name: "15" })); + + expect(onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + }); + + it("wizard: clears Due Date on a later Schedule On and floors the Due Date picker", async () => { + const onPatch = vi.fn(); + render( + , + ); + + const dueDateButtons = within(rowByLabel("Due Date").parentElement!).getAllByRole("button"); + fireEvent.click(dueDateButtons[dueDateButtons.length - 1]); + expect(await screen.findByRole("button", { name: "9" })).toBeDisabled(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + + const scheduleBlock = rowByLabel("Schedule On").parentElement!; + const scheduleButtons = within(scheduleBlock).getAllByRole("button"); + fireEvent.click(scheduleButtons[scheduleButtons.length - 1]); + const calendars = await screen.findAllByRole("button", { name: "15" }); + fireEvent.click(calendars[calendars.length - 1]); + + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + + it("inline creation row: clears Due Date on a later Schedule On", async () => { + const onChange = vi.fn(); + renderWithProviders( + + + + +
, + { withAuth: false }, + ); + + const cells = screen.getAllByRole("cell"); + const dateButtons = cells.flatMap((cell) => within(cell).queryAllByRole("button")); + const scheduleButton = dateButtons.find( + (b) => b.textContent?.includes(day(10).slice(5).replace("-", "/")) ?? false, + ); + fireEvent.click(scheduleButton ?? dateButtons[0]); + fireEvent.click(await screen.findByRole("button", { name: "15" })); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + + it("board row: sends the Due Date clear in the same schedule patch", () => { + const onPatch = vi.fn(); + const { result } = renderHook(() => + useWoTableRowHandlers({ + tableData: { + dragState: { current: { dragId: null, overId: null } }, + setDragViz: vi.fn(), + reorderRows: vi.fn(), + endDrag: vi.fn(), + revealScheduledRow: vi.fn(), + setSelectedId: vi.fn(), + } as never, + weekMonday: "2026-05-18", + onNavigateToDay: vi.fn(), + onOpenSlideOver: vi.fn(), + onOpenMedia: vi.fn(), + onPatch, + onScheduleHighlight: vi.fn(), + }), + ); + + result.current.onScheduleDate("wo-1", "2026-05-21", "2026-05-20"); + expect(onPatch).toHaveBeenLastCalledWith("wo-1", expect.objectContaining({ dueDate: "" })); + + result.current.onScheduleRange("wo-1", "2026-05-19", "2026-05-22", "2026-05-20"); + expect(onPatch.mock.lastCall?.[1]).not.toHaveProperty("dueDate"); + + result.current.onScheduleWeek("wo-1", "2026-05-25", "2026-05-20"); + expect(onPatch).toHaveBeenLastCalledWith( + "wo-1", + expect.objectContaining({ targetWeek: "2026-05-25", dueDate: "" }), + ); + }); +}); diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 0ca3f3c9..86b6020c 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -42,6 +42,16 @@ describe("saveExistingRowType", () => { expect(onPatch.mock.calls[0]![2]).toBeUndefined(); }); + it("does not warn when moving between SEV 3–5", () => { + const onPatch = vi.fn(); + + saveExistingRowType(onPatch, { ...row, severity: 5 }, "Reactive", 3); + + expect(toastWarn).not.toHaveBeenCalled(); + expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: 3 }); + expect(onPatch.mock.calls[0]![2]).toBeUndefined(); + }); + it("does not warn when clearing severity on a PM correction", () => { const onPatch = vi.fn(); diff --git a/src/test/app/(protected)/workorders/severity-badge.test.tsx b/src/test/app/(protected)/workorders/severity-badge.test.tsx new file mode 100644 index 00000000..2beb69ef --- /dev/null +++ b/src/test/app/(protected)/workorders/severity-badge.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SeverityBadge } from "@/app/(protected)/workorders/_components/list/table/cells/severity-badge"; + +describe("SeverityBadge", () => { + it("shows description, Accept/Respond deadline, and after-hours on hover", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByText("SEV 1")); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent( + "Business Critical Function Down · Accept: 15 min · Respond: 2 hrs · After hours: Yes", + ); + expect(tooltip).not.toHaveTextContent(/Quote|Tech Notes|Invoice|Turnaround/i); + + await user.unhover(screen.getByText("SEV 1")); + await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); + }); + + it("transcribes the SEV table row for the given severity", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByText("SEV 3")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Group Productivity Impaired · Accept: 2 hrs · Respond: 8 hrs (next day if after hours) · After hours: Approval required", + ); + }); + + it("is a button only when it can reopen the severity control", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + + rerender(); + const badge = screen.getByRole("button"); + expect(badge).toHaveTextContent("SEV 2"); + await user.click(badge); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx index f788e250..ba674966 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -161,4 +161,40 @@ describe("SlideOverExtrasTab Aveta", () => { expect(screen.getByRole("button", { name: "safety-form.pdf" })).toBeDisabled(); }); + + it("labels each extra item with its type and category", () => { + const sitePhoto: MediaFile = { + id: "m3", + name: "site.jpg", + fileType: "image", + url: "blob:3", + category: "Photo", + purpose: "Extra", + }; + const walkthrough: MediaFile = { + id: "m4", + name: "walkthrough.mp4", + fileType: "video", + url: "blob:4", + category: "Video", + purpose: "Extra", + }; + + renderWithProviders( + , + ); + + expect(screen.getByRole("group", { name: "Photo, Extra: site.jpg" })).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Video, Extra: walkthrough.mp4" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "PDF, Aveta: aveta-clearance.pdf" }), + ).toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index 87a74de5..dab9e3bd 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -278,4 +278,53 @@ describe("useSlideOverMediaState completion filter", () => { expect(remounted.result.current.completionMedia).toHaveLength(0); expect(remounted.result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]); }); + + it("shows Extra photos and videos in Extra Docs on PM work orders", () => { + const detail = detailWithMedia( + [ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + { + id: "11", + category: "Extra", + url: "https://cdn.example.com/walkthrough.mp4", + fileName: "walkthrough.mp4", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + { + id: "4", + category: "Extra", + url: "https://cdn.example.com/extra.pdf", + fileName: "extra.pdf", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ], + { id: 200, type: "PM" }, + ); + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.completionMedia).toHaveLength(0); + expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10", "11", "4"]); + expect(result.current.extrasUploads.map((item) => item.fileType)).toEqual([ + "image", + "video", + "pdf", + ]); + }); }); diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts new file mode 100644 index 00000000..60c004c7 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts @@ -0,0 +1,162 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const patchField = vi.hoisted(() => vi.fn()); +const patchFieldAsync = vi.hoisted(() => vi.fn()); + +vi.mock("react-toastify", () => ({ + toast: { + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ + useWorkOrdersListFilters: () => ({ + search: "", + handleSearchChange: vi.fn(), + advOpen: false, + setAdvOpen: vi.fn(), + advApplied: null, + advInitial: EMPTY_ADVANCED_FILTERS, + applyAdvancedFilters: vi.fn(), + clearAdvancedFilters: vi.fn(), + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: 0, + weekMonday: "2026-07-20", + selectedDayIso: "2026-07-20", + setWeekMonday: vi.fn(), + setSelectedDayIso: vi.fn(), + navigateToDay: vi.fn(), + dispatcherIds: new Set(), + setDispatcherIds: vi.fn(), + types: new Set(), + setTypes: vi.fn(), + }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ + useWorkOrdersTableData: () => ({ + rows: [], + totalCount: 0, + scopeTotalCount: 0, + isFetching: false, + isLoading: false, + setSelectedId: vi.fn(), + patchRow: vi.fn(), + clearRowPatch: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ + useWorkOrderTableMutations: () => ({ + patchField, + patchFieldAsync, + isPending: false, + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ data: [], isLoading: false }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: [] }), + useWorkOrderVendors: () => ({ data: [] }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + useDropdownOptionsByCategory: () => ({ data: [] }), +})); + +import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; + +function closableRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 77, + woNumber: "24866", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Dispatcher", + company: "Vendor Co", + vendorId: "7", + tech: "Tech One", + completedDate: "2026-09-15", + pm: "HVAC", + status: "In Progress", + docStatus: "No", + mediaCount: 0, + isPastDue: false, + rowVersion: "v1", + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + } as WorkOrderTableRow; +} + +describe("useWorkOrdersListPage completion document (SH-341)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("completes the WO without confirmation once Generate persists the document", async () => { + const saved = closableRow({ docStatus: "Yes", rowVersion: "v2" }); + patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: saved }); + patchFieldAsync.mockResolvedValueOnce({ + kind: "board", + row: { ...saved, status: "Completed" }, + }); + const { result } = renderHook(() => useWorkOrdersListPage()); + + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"); + + expect(patchFieldAsync).toHaveBeenNthCalledWith(1, 77, { docStatus: "Yes" }); + expect(patchFieldAsync).toHaveBeenNthCalledWith(2, 77, { status: "Completed" }, saved); + expect(patchField).not.toHaveBeenCalled(); + expect(result.current.confirmComplete).toBeNull(); + }); + + it("does not complete on Save draft or Not required", async () => { + const { result } = renderHook(() => useWorkOrdersListPage()); + + patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "No" }) }); + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "No"); + patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "NN" }) }); + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "NN"); + + expect(patchFieldAsync).toHaveBeenCalledTimes(2); + expect(patchFieldAsync).not.toHaveBeenCalledWith( + 77, + { status: "Completed" }, + expect.anything(), + ); + }); + + it("keeps the closability gate: a missing service leaves the status unchanged", async () => { + patchFieldAsync.mockResolvedValueOnce({ + kind: "board", + row: closableRow({ docStatus: "Yes", pm: "" }), + }); + const { result } = renderHook(() => useWorkOrdersListPage()); + + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"); + + expect(patchFieldAsync).toHaveBeenCalledTimes(1); + }); + + it("keeps the generated document when the automatic completion is rejected", async () => { + patchFieldAsync.mockResolvedValueOnce({ + kind: "board", + row: closableRow({ docStatus: "Yes" }), + }); + patchFieldAsync.mockRejectedValueOnce(new Error("Work order was modified.")); + const { result } = renderHook(() => useWorkOrdersListPage()); + + await expect( + result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"), + ).resolves.toBeUndefined(); + expect(patchFieldAsync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx b/src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx new file mode 100644 index 00000000..f5817e84 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields"; +import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row"; +import { + EMPTY_TABLE_DRAFT, + type WorkOrderTableDraft, +} from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({ + TypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ + AssignedToCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({ + DueDateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); + +function renderInlineRow(draft: WorkOrderTableDraft, onSave = vi.fn()) { + const ui = (d: WorkOrderTableDraft) => ( + + + + +
+ ); + const view = renderWithProviders(ui(draft), { withAuth: false }); + return { ...view, rerenderDraft: (d: WorkOrderTableDraft) => view.rerender(ui(d)) }; +} + +const COMPLETE_DRAFT: WorkOrderTableDraft = { + ...EMPTY_TABLE_DRAFT, + site: "BK5", + locationId: 9, + woNumber: "12345678901", + pocName: "Dana", + pocPhone: "555-0100", +}; + +describe("WoTableInlineRow required fields", () => { + it("marks Site and WO # as required without showing errors before a Save attempt", () => { + renderInlineRow(EMPTY_TABLE_DRAFT); + + expect(screen.getByRole("button", { name: /^Site \*$/ })).toBeInTheDocument(); + expect(screen.getByText("Insert WO #")).toHaveTextContent("Insert WO # *"); + expect(screen.queryByText("Site is required")).not.toBeInTheDocument(); + expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument(); + }); + + it("reveals field errors only after Save is attempted, then clears them once corrected", () => { + const onSave = vi.fn(); + const { rerenderDraft } = renderInlineRow(EMPTY_TABLE_DRAFT, onSave); + + fireEvent.click(screen.getByRole("button", { name: "Save work order" })); + + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByText("Site is required")).toBeInTheDocument(); + expect(screen.getByText("Work order number is required")).toBeInTheDocument(); + + rerenderDraft(COMPLETE_DRAFT); + + expect(screen.queryByText("Site is required")).not.toBeInTheDocument(); + expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save work order" })); + expect(onSave).toHaveBeenCalledTimes(1); + }); +}); + +describe("PocContactFields required primary contact", () => { + it("labels POC name and phone with a required asterisk when the contact is required", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByLabelText(/POC name/)).toHaveAttribute("placeholder", "POC name"); + expect(screen.getByLabelText(/POC phone/)).toHaveAttribute("placeholder", "POC phone"); + expect(screen.getByText("POC name").closest("label")).toHaveTextContent("POC name*"); + expect(screen.getByText("POC phone").closest("label")).toHaveTextContent("POC phone*"); + expect(screen.queryByText("Contact name is required")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index c3fb07b9..7e4df369 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -189,7 +189,12 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({ describe("WorkOrdersListHeader slice affordances", () => { it("hides New WO and Advanced Filters when callbacks are omitted", () => { renderWithProviders( - , + , { withAuth: false }, ); @@ -202,6 +207,7 @@ describe("WorkOrdersListHeader slice affordances", () => { renderWithProviders( ({ + searchBoard: vi.fn(), + getBoard: vi.fn(), + emptyList: [] as const, +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: T) => value, +})); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + searchBoard: (...args: unknown[]) => searchBoard(...args), + getBoard: (...args: unknown[]) => getBoard(...args), + }, +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: emptyList }), + useWorkOrderVendors: () => ({ data: emptyList }), +})); + +import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; +import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; + +const EMPTY_RESULT = { items: [], totalCount: 0, page: 0, pageSize: 25 }; + +function useBoardWithFilters() { + const filters = useWorkOrdersListFilters(); + useWorkOrdersBoardSource(filters); + return filters; +} + +function renderBoardSource() { + const client = createTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useBoardWithFilters(), { wrapper }); +} + +describe("keyword search scope indicator", () => { + it("shows no scope badge while the search box is empty", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.queryByText(/searching within/i)).not.toBeInTheDocument(); + }); + + it("names the active week while a keyword is typed", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Searching within 5/4–5/8")).toBeInTheDocument(); + }); + + it("hides the scope label while Advanced Filters are active", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.queryByText(/searching within/i)).not.toBeInTheDocument(); + }); +}); + +describe("keyword search vs Advanced Filters scope", () => { + beforeEach(() => { + searchBoard.mockReset().mockResolvedValue(EMPTY_RESULT); + getBoard.mockReset().mockResolvedValue(EMPTY_RESULT); + }); + + it("scopes keyword search to the active week", async () => { + const { result } = renderBoardSource(); + + act(() => { + result.current.setWeekMonday("2026-05-04"); + result.current.handleSearchChange("hvac"); + }); + + await waitFor(() => + expect(getBoard).toHaveBeenCalledWith( + expect.objectContaining({ weekStart: "2026-05-04", weekEnd: "2026-05-08", search: "hvac" }), + ), + ); + expect(searchBoard).not.toHaveBeenCalled(); + }); + + it("keeps Advanced Filters global and ignores the week-scoped keyword", async () => { + const { result } = renderBoardSource(); + + act(() => { + result.current.handleSearchChange("hvac"); + result.current.applyAdvancedFilters({ ...EMPTY_ADVANCED_FILTERS, rescheduled: true }); + }); + + await waitFor(() => expect(searchBoard).toHaveBeenCalled()); + const params = searchBoard.mock.lastCall?.[0] as Record; + expect(params.search).toBeUndefined(); + expect(params).not.toHaveProperty("weekStart"); + }); +}); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index d3e1095d..97f364b5 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -224,6 +224,35 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate); expect(serverState.status).toBe(BASE_SERVER.status); }); + it("sends the follow-up status patch with the rowVersion of the just-saved row (SH-341)", async () => { + const staleTableRow = { ...BASE_ROW }; + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: () => undefined, + getRow: () => staleTableRow, + }), + { wrapper: makeWrapper() }, + ); + + let saved: Awaited> | undefined; + await act(async () => { + saved = await result.current.patchFieldAsync(42, { docStatus: "Yes" }); + }); + expect(saved?.kind).toBe("board"); + const savedRow = saved?.kind === "board" ? saved.row : undefined; + + await act(async () => { + await result.current.patchFieldAsync(42, { status: "Completed" }, savedRow); + }); + + expect(patchBoardField).toHaveBeenLastCalledWith(42, { + field: "lifecycleStatus", + value: "Completed", + workOrderVersion: "AAAAAAA=-next", + }); + }); }); describe("maybeAutoSchedule on board patch", () => { diff --git a/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts b/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts new file mode 100644 index 00000000..ab12e39e --- /dev/null +++ b/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; + +function closablePmRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 42, + woNumber: "24866", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Dispatcher", + company: "Vendor Co", + vendorId: "7", + tech: "Tech One", + completedDate: "2026-09-15", + pm: "HVAC", + status: "In Progress", + docStatus: "Yes", + mediaCount: 0, + isPastDue: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + } as WorkOrderTableRow; +} + +describe("shouldCompleteAfterCompletionDoc (SH-341)", () => { + it("completes a closable PM once the generated document is persisted", () => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow())).toBe(true); + }); + + it("never completes on Save draft or Not required", () => { + expect(shouldCompleteAfterCompletionDoc("No", closablePmRow({ docStatus: "No" }))).toBe(false); + expect(shouldCompleteAfterCompletionDoc("NN", closablePmRow({ docStatus: "NN" }))).toBe(false); + }); + + it("does not complete when the server did not persist the document as generated", () => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ docStatus: "No" }))).toBe(false); + }); + + it.each([ + ["dispatcher", { dispatcherId: "", dispatcherName: "" }], + ["vendor", { company: "", vendorId: "" }], + ["completed date", { completedDate: "" }], + ["real WO number", { woNumber: "SH02137" }], + ["service", { pm: "" }], + ["pending uplift", { upliftSummary: { hasUplift: true, pendingCount: 1 } }], + ["photo on a Reactive WO", { type: "Reactive" as const, mediaCount: 0 }], + ])("keeps the SH-114 closability gate: missing %s blocks completion", (_label, overrides) => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow(overrides))).toBe(false); + }); + + it("completes a Reactive WO that has its completion photo", () => { + expect( + shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ type: "Reactive", mediaCount: 1 })), + ).toBe(true); + }); + + it("leaves Completed, Canceled, and Past Due rows unchanged", () => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Completed" }))).toBe( + false, + ); + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Canceled" }))).toBe( + false, + ); + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ isPastDue: true }))).toBe(false); + }); +}); diff --git a/src/test/domain/work-orders/utils/due-date-floor.test.ts b/src/test/domain/work-orders/utils/due-date-floor.test.ts new file mode 100644 index 00000000..b4c52c1b --- /dev/null +++ b/src/test/domain/work-orders/utils/due-date-floor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + clearDueDateBeforeSchedule, + isDueDateBeforeFloor, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; + +describe("due date floor (SH-356)", () => { + it("uses the concrete or range-start Schedule On, else the target week's first day", () => { + expect(resolveDueDateFloor({ scheduledOn: "2026-09-10", targetWeek: "" })).toBe("2026-09-10"); + expect(resolveDueDateFloor({ scheduledOn: "", targetWeek: "2026-09-14" })).toBe("2026-09-14"); + expect(resolveDueDateFloor({ scheduledOn: "", targetWeek: "" })).toBe(""); + expect(resolveDueDateFloor({ scheduledOn: null, targetWeek: undefined })).toBe(""); + }); + + it("flags only a Due Date strictly before the floor", () => { + expect(isDueDateBeforeFloor("2026-09-09", "2026-09-10")).toBe(true); + expect(isDueDateBeforeFloor("2026-09-10", "2026-09-10")).toBe(false); + expect(isDueDateBeforeFloor("", "2026-09-10")).toBe(false); + expect(isDueDateBeforeFloor("2026-09-09", "")).toBe(false); + }); + + it("clears (never bumps) a Due Date the new schedule passes", () => { + expect(clearDueDateBeforeSchedule("2026-09-12", "2026-09-15")).toEqual({ dueDate: "" }); + expect(clearDueDateBeforeSchedule("2026-09-12", "2026-09-12")).toEqual({}); + expect(clearDueDateBeforeSchedule("2026-09-20", "2026-09-15")).toEqual({}); + expect(clearDueDateBeforeSchedule("2026-09-12", "")).toEqual({}); + }); +}); diff --git a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts index 077eff57..1970db61 100644 --- a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts +++ b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import { + hasCompletionMediaGallery, isCompletionSurfaceMedia, isExtrasSurfaceMedia, } from "@/domain/work-orders/utils/partition-slide-over-media"; @@ -76,4 +77,33 @@ describe("partition-slide-over-media", () => { }), ).toBe(true); }); + + it("renders a Completion gallery only for Reactive and Emergency work orders", () => { + expect(hasCompletionMediaGallery("Reactive")).toBe(true); + expect(hasCompletionMediaGallery("Emergency")).toBe(true); + expect(hasCompletionMediaGallery("PM")).toBe(false); + expect(hasCompletionMediaGallery("Overdue")).toBe(false); + expect(hasCompletionMediaGallery("")).toBe(false); + }); + + it("puts Extra photos and videos on Extra Docs when Completion has no gallery", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const video = file({ id: "11", fileType: "video", purpose: "Extra", category: "Video" }); + + for (const item of [photo, video]) { + expect(isCompletionSurfaceMedia(item, undefined, false)).toBe(false); + expect(isExtrasSurfaceMedia(item, undefined, false)).toBe(true); + } + }); + + it("ignores stale completion ownership when Completion has no gallery", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(["10"]), + extraOwnedIds: new Set(), + }; + + expect(isCompletionSurfaceMedia(photo, ownership, false)).toBe(false); + expect(isExtrasSurfaceMedia(photo, ownership, false)).toBe(true); + }); }); diff --git a/src/test/domain/work-orders/utils/severity-escalation.test.ts b/src/test/domain/work-orders/utils/severity-escalation.test.ts index 812e40f9..22c2cc9a 100644 --- a/src/test/domain/work-orders/utils/severity-escalation.test.ts +++ b/src/test/domain/work-orders/utils/severity-escalation.test.ts @@ -18,6 +18,21 @@ describe("shouldAnnounceSeverityEscalation", () => { expect(shouldAnnounceSeverityEscalation(2, 3)).toBe(false); }); + it("announces when severity rises from SEV 3–5 into SEV 2", () => { + expect(shouldAnnounceSeverityEscalation(5, 2)).toBe(true); + expect(shouldAnnounceSeverityEscalation(4, 2)).toBe(true); + }); + + it("does not announce moves between SEV 3–5", () => { + expect(shouldAnnounceSeverityEscalation(5, 3)).toBe(false); + expect(shouldAnnounceSeverityEscalation(4, 3)).toBe(false); + expect(shouldAnnounceSeverityEscalation(3, 5)).toBe(false); + }); + + it("does not announce SEV 1 to SEV 2 (already critical)", () => { + expect(shouldAnnounceSeverityEscalation(1, 2)).toBe(false); + }); + it("does not announce SEV 2 to SEV 1 (already critical)", () => { expect(shouldAnnounceSeverityEscalation(2, 1)).toBe(false); });