) => void;
+}) {
+ return (
+
+
+ onPatch({
+ scheduledOn: date,
+ scheduleWeekOnly: false,
+ targetWeek: "",
+ isAddOnOverride: false,
+ })
+ }
+ onSaveWeek={(week) =>
+ onPatch({
+ scheduledOn: "",
+ targetWeek: week,
+ scheduleWeekOnly: true,
+ isAddOnOverride: false,
+ })
+ }
+ />
+
+ );
+}
+
export function WoTableInlineRowCells({
draft,
weekDefault,
@@ -84,31 +122,7 @@ export function WoTableInlineRowCells({
/>
-
-
- onPatch({
- scheduledOn: date,
- scheduleWeekOnly: false,
- targetWeek: "",
- isAddOnOverride: false,
- })
- }
- onSaveWeek={(week) =>
- onPatch({
- scheduledOn: "",
- targetWeek: week,
- scheduleWeekOnly: true,
- isAddOnOverride: false,
- })
- }
- />
-
+
{
expect(payload.isAddOn).toBe(true);
});
- it("derives isAddOn from range start via cutoff (range persists scheduledDate)", () => {
+ it("derives isAddOn from range start via cutoff (range omits scheduledDate on create)", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
@@ -96,7 +96,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
isAddOnOverride: false,
});
- expect(payload.scheduledDate).toBe("2026-07-08");
+ // On create, range mode does not persist scheduledDate (notes carry the range).
+ expect(payload.scheduledDate).toBeUndefined();
// createdAt defaults to now; after prior-Saturday 2026-07-04 for that week → true
expect(payload.isAddOn).toBe(true);
});
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 6ce07630..275f7b79 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
@@ -42,14 +42,12 @@ function makeWrapper() {
type ServerState = {
status: string;
scheduledDate: string;
- completedDate: string;
docStatus: CompletionDocStatus;
};
const BASE_SERVER: ServerState = {
status: "Open",
scheduledDate: "2026-06-01",
- completedDate: "",
docStatus: "No",
};
@@ -61,7 +59,6 @@ const BASE_ROW = {
dispatcherId: "d1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01",
- completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
@@ -129,14 +126,6 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
rowVersion: `${payload.workOrderVersion}-next`,
};
}
- if (payload.field === "completedDate" && typeof payload.value === "string") {
- serverState = { ...serverState, completedDate: payload.value };
- rowState = {
- ...rowState,
- completedDate: payload.value,
- rowVersion: `${payload.workOrderVersion}-date`,
- };
- }
events.push(`boardPatch:${payload.field}:end`);
return { ...rowState };
},
@@ -170,46 +159,6 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
expect(getById).not.toHaveBeenCalled();
expect(serverState.docStatus).toBe("NN");
});
-
- it("serializes board docStatus and completedDate via PATCH without racing or EditWorkorder", async () => {
- const onPatch = vi.fn();
- const { result } = renderHook(
- () =>
- useWorkOrderTableMutations({
- onPatch,
- clearPatch: () => undefined,
- getRow: () => rowState,
- }),
- { wrapper: makeWrapper() },
- );
-
- act(() => {
- result.current.patchField(42, { docStatus: "Yes" });
- result.current.patchField(42, { completedDate: "2026-07-20" });
- });
-
- await waitFor(() => expect(events).toContain("boardPatch:completedDate:end"));
- await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
-
- expect(events).toEqual([
- "boardPatch:docStatus:start",
- "boardPatch:docStatus:end",
- "boardPatch:completedDate:start",
- "boardPatch:completedDate:end",
- ]);
-
- expect(getById).not.toHaveBeenCalled();
- expect(update).not.toHaveBeenCalled();
- expect(patchBoardField).toHaveBeenCalledWith(
- 42,
- expect.objectContaining({ field: "completedDate", value: "2026-07-20" }),
- );
-
- expect(serverState.docStatus).toBe("Yes");
- expect(serverState.completedDate).toBe("2026-07-20");
- expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
- expect(serverState.status).toBe(BASE_SERVER.status);
- });
});
describe("applySchedulePatch isAddOn optimistic clear", () => {
From 83ae05be272aa734456af2469ea1fc134c2fdd91 Mon Sep 17 00:00:00 2001
From: "arthur.bassi"
Date: Wed, 12 Aug 2026 16:07:26 -0300
Subject: [PATCH 4/5] chore(work-orders): resolve Add-On indicator conflicts
with current dev
---
scripts/governance-baseline.json | 13 +-
.../_components/comp-doc/comp-doc-co-line.tsx | 19 ++
.../comp-doc/comp-doc-detail-row.tsx | 39 +++
.../comp-doc/comp-doc-details-section.tsx | 75 +++++
.../comp-doc-dialog-footer-draft-actions.tsx | 70 +++++
.../comp-doc/comp-doc-dialog-footer-draft.tsx | 50 ++++
...mp-doc-dialog-footer-generated-actions.tsx | 62 ++++
.../comp-doc-dialog-footer-generated.tsx | 35 +++
...doc-dialog-footer-not-required-actions.tsx | 53 ++++
.../comp-doc-dialog-footer-not-required.tsx | 47 ++++
.../comp-doc/comp-doc-dialog-footer.tsx | 94 +++++++
.../comp-doc/comp-doc-dialog-header.tsx | 96 +++++++
.../comp-doc-dialog-resource-links.tsx | 48 ++++
.../_components/comp-doc/comp-doc-dialog.tsx | 132 +++++++++
.../comp-doc/comp-doc-draft-toast.tsx | 29 ++
.../comp-doc/comp-doc-edit-row.tsx | 51 ++++
.../comp-doc/comp-doc-editable-row.tsx | 54 ++++
.../comp-doc/comp-doc-footer-btn-sx.ts | 8 +
.../comp-doc/comp-doc-footer-button-sx.ts | 8 +
.../comp-doc/comp-doc-not-required-stamp.tsx | 25 ++
.../comp-doc/comp-doc-notes-field.tsx | 50 ++++
.../comp-doc/comp-doc-preview-badge.tsx | 32 +++
.../comp-doc-preview-company-header.tsx | 36 +++
.../comp-doc/comp-doc-preview-document.tsx | 58 ++++
.../comp-doc/comp-doc-preview-header.tsx | 36 +++
.../comp-doc-preview-not-required-stamp.tsx | 25 ++
.../comp-doc-preview-procedure-notes.tsx | 68 +++++
.../comp-doc/comp-doc-preview-procedure.tsx | 87 ++++++
.../comp-doc-preview-service-info.tsx | 89 ++++++
.../comp-doc-preview-status-badge.tsx | 32 +++
.../comp-doc/comp-doc-preview-summary.tsx | 96 +++++++
.../comp-doc-preview-vendor-safety.tsx | 35 +++
.../_components/comp-doc/comp-doc-preview.tsx | 65 +++++
.../comp-doc/comp-doc-procedure-list.tsx | 32 +++
.../_components/comp-doc/comp-doc-sidebar.tsx | 174 ++++++++++++
.../comp-doc/comp-doc-status-card.tsx | 61 ++++
.../comp-doc/use-comp-doc-dialog-seed.ts | 59 ++++
.../comp-doc/use-comp-doc-dialog.tsx | 179 ++++++++++++
.../use-comp-doc-not-required-actions.ts | 58 ++++
.../comp-doc/use-comp-doc-pdf-actions.ts | 98 +++++++
.../comp-doc/use-comp-doc-persist-flow.ts | 84 ++++++
.../_components/detail/audit-timeline.tsx | 67 +++++
.../detail/slide-over-audit-list.tsx | 164 +++++++++++
.../_components/detail/slide-over-footer.tsx | 143 ++++++++++
.../_components/detail/slide-over-header.tsx | 106 +++++++
.../detail/slide-over-info-row.tsx | 22 ++
.../detail/tabs/slide-over-audit-tab.tsx | 11 +
.../detail/tabs/slide-over-comments-tab.tsx | 265 ++++++++++++++++++
.../tabs/slide-over-completion-actions.tsx | 92 ++++++
.../tabs/slide-over-completion-media.tsx | 52 ++++
.../slide-over-completion-readonly-status.tsx | 45 +++
.../tabs/slide-over-completion-signoffs.tsx | 35 +++
.../detail/tabs/slide-over-completion-tab.tsx | 143 ++++++++++
.../detail/tabs/slide-over-doc-badge.tsx | 44 +++
.../detail/tabs/slide-over-extras-tab.tsx | 163 +++++++++++
.../slide-over-info-tab-assigned-field.tsx | 28 ++
.../tabs/slide-over-info-tab-display-view.tsx | 92 ++++++
.../tabs/slide-over-info-tab-edit-view.tsx | 160 +++++++++++
.../tabs/slide-over-info-tab-status-field.tsx | 26 ++
.../tabs/slide-over-info-tab-vendor-field.tsx | 45 +++
.../detail/tabs/slide-over-info-tab.tsx | 64 +++++
.../detail/use-slide-over-edit-state.ts | 162 +++++++++++
.../detail/use-slide-over-media-state.ts | 101 +++++++
.../detail/use-work-order-slide-over.ts | 160 +++++++++++
.../detail/work-order-activity-section.tsx | 101 +++++++
.../detail/work-order-assignee-menu.tsx | 56 ++++
.../work-order-description-accordion.tsx | 20 ++
.../detail/work-order-detail-sections.tsx | 49 ++++
.../detail/work-order-details-card.tsx | 45 +++
.../work-order-dispatches-accordion.tsx | 85 ++++++
.../detail/work-order-simple-accordions.tsx | 61 ++++
.../detail/work-order-slide-over-tab-nav.tsx | 52 ++++
.../work-order-slide-over-tab-panel.tsx | 33 +++
.../detail/work-order-slide-over-tabs.tsx | 156 +++++++++++
.../detail/work-order-slide-over.tsx | 108 +++++++
.../detail/work-order-status-menu.tsx | 50 ++++
.../table/build-inline-draft-create-input.ts | 7 +-
.../list/table/cells/comp-doc-cell.tsx | 18 ++
.../list/table/cells/date-cell.tsx | 18 ++
.../list/table/cells/site-cell.tsx | 14 +-
.../table/cells/site-dialog-poc-fields.tsx | 76 +++--
.../list/table/cells/site-dialog.tsx | 143 ++++------
.../list/table/cells/use-site-dialog-state.ts | 142 ++++++++++
.../list/table/inline-create-field.tsx | 32 +++
.../list/table/to-inline-draft-row.ts | 1 +
.../_components/list/table/wo-table-cols.ts | 3 +-
.../table/wo-table-inline-identity-cells.tsx | 93 ++++++
.../table/wo-table-inline-row-actions.tsx | 11 +-
.../list/table/wo-table-inline-row-cells.tsx | 76 +++--
.../list/table/wo-table-inline-row.tsx | 87 ++++--
.../list/table/wo-table-row-service-cells.tsx | 16 +-
.../list/work-order-cancel-dialog.tsx | 50 ++++
.../list/work-orders-list-overlays.tsx | 90 ++++++
.../_components/media/media-upload-modal.tsx | 93 +++++-
.../media/media-uploader-dropzone.tsx | 60 ++++
.../media/media-uploader-grid-item-chrome.tsx | 113 ++++++++
.../media/media-uploader-grid-item-meta.tsx | 67 +++++
.../media-uploader-grid-item-preview.tsx | 52 ++++
.../media/media-uploader-grid-item.tsx | 46 +++
.../_components/media/media-uploader-grid.tsx | 42 +++
.../_components/media/media-uploader.tsx | 208 ++++++--------
.../_hooks/create-comp-doc-dialog-actions.ts | 170 +++++++++++
.../workorders/_hooks/use-comp-doc-dialog.ts | 157 +++++++++++
.../_hooks/use-media-upload-session.ts | 199 +++++++++++++
.../_hooks/use-work-orders-list-page.ts | 131 +++++++++
.../_hooks/use-work-orders-table-ui-state.ts | 8 +-
src/app/(protected)/workorders/index.tsx | 147 +++++-----
.../api/work-order-board-documents-api.ts | 50 +++-
.../mappers/assign-wizard-schedule-fields.ts | 4 +-
.../work-orders/mappers/doc-status-mapper.ts | 28 ++
.../mappers/expand-scalar-board-ops.ts | 9 +-
.../mappers/wizard-draft-mapper.ts | 1 +
.../mappers/work-order-board-detail-mapper.ts | 8 +-
.../work-orders/mappers/work-order-mapper.ts | 10 +-
.../mappers/work-order-table-row-mapper.ts | 1 +
.../work-orders/mocks/wo-wizard-mock-data.ts | 1 +
.../types/work-order-board-patch.ts | 8 +
.../work-orders/types/work-order-board.ts | 3 +
.../work-orders/types/work-order-detail.ts | 6 +
.../work-orders/types/work-order-media.ts | 6 +
.../work-orders/types/work-order-table-row.ts | 2 +
.../use-cases/use-work-order-mutations.ts | 45 ++-
.../use-work-order-table-mutations.ts | 126 ++++++---
.../utils/completion-doc-service-gate.ts | 4 +
.../utils/completion-doc-service-title.ts | 19 +-
.../utils/get-inline-create-missing.ts | 62 ++++
.../utils/run-sequential-uploads.ts | 24 ++
.../work-orders/utils/slide-over-draft.ts | 4 +-
.../utils/work-order-media-limits.ts | 17 ++
.../utils/work-order-patch-queue.ts | 20 ++
.../build-inline-draft-create-input.test.ts | 55 ++++
.../comp-doc-cell-service-gate.test.tsx | 29 ++
...mp-doc-dialog-completion-contract.test.tsx | 230 +++++++++++++++
.../comp-doc-dialog-footer-exclusive.test.tsx | 53 ++++
.../media-upload-modal-api.test.tsx | 115 ++++++++
.../use-comp-doc-dialog-address-seed.test.tsx | 149 ++++++++++
.../use-media-upload-session.test.tsx | 206 ++++++++++++++
.../workorders/use-site-dialog-state.test.tsx | 112 ++++++++
.../use-slide-over-media-state.test.tsx | 143 ++++++++++
.../wo-table-unscheduled-highlight.test.tsx | 1 +
...work-order-slide-over-failed-save.test.tsx | 172 ++++++++++++
...ork-orders-advanced-search-wiring.test.tsx | 7 +
...ork-orders-board-core-affordances.test.tsx | 23 +-
.../app/v/dispatch-snapshot-details.test.tsx | 52 ++++
.../work-orders/api/work-orders-api.test.ts | 69 ++++-
.../completed-date-board-column.test.ts | 26 ++
.../work-order-board-detail-mapper.test.ts | 29 ++
.../mappers/work-order-board-mapper.test.ts | 15 +
.../mappers/work-order-form-payload.test.ts | 14 +
.../mappers/work-order-mapper.test.ts | 37 +++
...der-table-mutations-completed-date.test.ts | 50 ++++
.../use-work-order-table-mutations.test.tsx | 51 ++++
.../completion-doc-service-title.test.ts | 27 ++
.../utils/get-inline-create-missing.test.ts | 82 ++++++
.../utils/run-sequential-uploads.test.ts | 65 +++++
.../utils/slide-over-draft.test.ts | 15 +
.../slide-over-failed-save-baseline.test.ts | 100 +++++++
.../utils/slide-over-save-contract.test.ts | 76 +++++
.../utils/table-filter-utils.test.ts | 1 +
.../utils/wizard-date-utils.test.ts | 28 +-
.../utils/work-order-media-limits.test.ts | 14 +
.../utils/work-order-patch-queue.test.ts | 62 ++++
162 files changed, 9912 insertions(+), 492 deletions(-)
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-summary.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-status-card.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts
create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-persist-flow.ts
create mode 100644 src/app/(protected)/workorders/_components/detail/audit-timeline.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/slide-over-header.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts
create mode 100644 src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts
create mode 100644 src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-assignee-menu.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx
create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx
create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
create mode 100644 src/app/(protected)/workorders/_components/list/table/inline-create-field.tsx
create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx
create mode 100644 src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx
create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx
create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item-chrome.tsx
create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx
create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx
create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx
create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx
create mode 100644 src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts
create mode 100644 src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
create mode 100644 src/app/(protected)/workorders/_hooks/use-media-upload-session.ts
create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
create mode 100644 src/domain/work-orders/mappers/doc-status-mapper.ts
create mode 100644 src/domain/work-orders/types/work-order-board-patch.ts
create mode 100644 src/domain/work-orders/utils/completion-doc-service-gate.ts
create mode 100644 src/domain/work-orders/utils/get-inline-create-missing.ts
create mode 100644 src/domain/work-orders/utils/run-sequential-uploads.ts
create mode 100644 src/domain/work-orders/utils/work-order-patch-queue.ts
create mode 100644 src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts
create mode 100644 src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx
create mode 100644 src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
create mode 100644 src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx
create mode 100644 src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
create mode 100644 src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
create mode 100644 src/test/app/(protected)/workorders/use-media-upload-session.test.tsx
create mode 100644 src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
create mode 100644 src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
create mode 100644 src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
create mode 100644 src/test/app/v/dispatch-snapshot-details.test.tsx
create mode 100644 src/test/domain/work-orders/mappers/completed-date-board-column.test.ts
create mode 100644 src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts
create mode 100644 src/test/domain/work-orders/utils/completion-doc-service-title.test.ts
create mode 100644 src/test/domain/work-orders/utils/get-inline-create-missing.test.ts
create mode 100644 src/test/domain/work-orders/utils/run-sequential-uploads.test.ts
create mode 100644 src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
create mode 100644 src/test/domain/work-orders/utils/slide-over-save-contract.test.ts
create mode 100644 src/test/domain/work-orders/utils/work-order-patch-queue.test.ts
diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json
index 401e1e70..3862e898 100644
--- a/scripts/governance-baseline.json
+++ b/scripts/governance-baseline.json
@@ -2,16 +2,5 @@
"version": 1,
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
"maxFileLines": 500,
- "godfileDebt": [
- {
- "path": "src/app/(protected)/workorders/[id].tsx",
- "maxLines": 797,
- "reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
- },
- {
- "path": "src/app/(protected)/vendors/index.tsx",
- "maxLines": 612,
- "reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
- }
- ]
+ "godfileDebt": []
}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx
new file mode 100644
index 00000000..5d3804bb
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx
@@ -0,0 +1,19 @@
+import type { ElementType } from "react";
+
+type CompDocCoLineProps = {
+ icon: ElementType;
+ text: string;
+ bold?: boolean;
+};
+
+export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
+ return (
+
+ {text}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx
new file mode 100644
index 00000000..c5f08558
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx
@@ -0,0 +1,39 @@
+import type { ElementType } from "react";
+
+type CompDocDetailRowProps = {
+ icon: ElementType;
+ label: string;
+ value: string;
+ last?: boolean;
+};
+
+export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
+ return (
+
+
+
+
{label}
+
+ {value}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx
new file mode 100644
index 00000000..3a38f2e3
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx
@@ -0,0 +1,75 @@
+import { Building2, MapPin, Users, Wrench } from "lucide-react";
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { CompDocDetailRow } from "./comp-doc-detail-row";
+import { CompDocEditableRow } from "./comp-doc-editable-row";
+
+type CompDocDetailsSectionProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ editAddress: string;
+ onAddressChange: (value: string) => void;
+ editDateCompleted: string;
+ onDateCompletedChange: (value: string) => void;
+ amazonRep: string;
+ seaRep: string;
+ isGenerated: boolean;
+};
+
+export function CompDocDetailsSection({
+ row,
+ site,
+ editAddress,
+ onAddressChange,
+ editDateCompleted,
+ onDateCompletedChange,
+ amazonRep,
+ seaRep,
+ isGenerated,
+}: CompDocDetailsSectionProps) {
+ return (
+
+
+ Work Order Details
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx
new file mode 100644
index 00000000..86749598
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx
@@ -0,0 +1,70 @@
+import type { RefObject } from "react";
+import { Ban, Save, Sparkles, Upload } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
+
+type CompDocDialogFooterDraftActionsProps = {
+ rowVersion?: string | number | null;
+ isUploading: boolean;
+ pdfInputRef: RefObject;
+ onMarkNotRequired: () => void;
+ onSaveDraft: () => void;
+ onGenerate: () => void;
+};
+
+export function CompDocDialogFooterDraftActions({
+ rowVersion,
+ isUploading,
+ pdfInputRef,
+ onMarkNotRequired,
+ onSaveDraft,
+ onGenerate,
+}: CompDocDialogFooterDraftActionsProps) {
+ return (
+ <>
+
+ Mark as not required
+
+
+
+ Save draft
+
+ pdfInputRef.current?.click()}
+ startIcon={!isUploading ? : undefined}
+ >
+ Upload PDF
+
+
+ Generate
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx
new file mode 100644
index 00000000..fad1529f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx
@@ -0,0 +1,50 @@
+import { Ban, Save, Sparkles } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
+
+type CompDocDialogFooterDraftProps = {
+ onMarkNotRequired: () => void;
+ onSaveDraft: () => void;
+ onGenerate: () => void;
+};
+
+export function CompDocDialogFooterDraft({
+ onMarkNotRequired,
+ onSaveDraft,
+ onGenerate,
+}: CompDocDialogFooterDraftProps) {
+ return (
+ <>
+
+ Mark as not required
+
+
+
+ Save draft
+
+
+ Generate
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx
new file mode 100644
index 00000000..5c006a97
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx
@@ -0,0 +1,62 @@
+import type { RefObject } from "react";
+import { CheckCircle2, Download, Upload } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
+
+type CompDocDialogFooterGeneratedActionsProps = {
+ generatedAt: string;
+ signOffAttachment?: string | null;
+ isDocumentsReadOnly: boolean;
+ rowVersion?: string | number | null;
+ isUploading: boolean;
+ pdfInputRef: RefObject;
+ onDownloadPdf: () => void;
+};
+
+export function CompDocDialogFooterGeneratedActions({
+ generatedAt,
+ signOffAttachment,
+ isDocumentsReadOnly,
+ rowVersion,
+ isUploading,
+ pdfInputRef,
+ onDownloadPdf,
+}: CompDocDialogFooterGeneratedActionsProps) {
+ return (
+ <>
+
+ {" "}
+ {signOffAttachment ? "Document on file" : `Generated ${generatedAt}`}
+
+
+ {!isDocumentsReadOnly && (
+ pdfInputRef.current?.click()}
+ startIcon={!isUploading ? : undefined}
+ >
+ Replace PDF
+
+ )}
+
+ Download PDF
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx
new file mode 100644
index 00000000..91b9792c
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx
@@ -0,0 +1,35 @@
+import { CheckCircle2, Download } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
+
+type CompDocDialogFooterGeneratedProps = {
+ generatedAt: string;
+ onDownloadPdf: () => void;
+};
+
+export function CompDocDialogFooterGenerated({
+ generatedAt,
+ onDownloadPdf,
+}: CompDocDialogFooterGeneratedProps) {
+ return (
+ <>
+
+ Generated {generatedAt}
+
+
+
+ Download PDF
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx
new file mode 100644
index 00000000..79d16bca
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx
@@ -0,0 +1,53 @@
+import { Ban, Sparkles, Undo2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
+
+type CompDocDialogFooterNotRequiredActionsProps = {
+ isDocumentsReadOnly: boolean;
+ onUndo: () => void;
+};
+
+export function CompDocDialogFooterNotRequiredActions({
+ isDocumentsReadOnly,
+ onUndo,
+}: CompDocDialogFooterNotRequiredActionsProps) {
+ return (
+ <>
+
+ Marked{" "}
+ not required for this work order
+
+
+ {!isDocumentsReadOnly && (
+
+ Undo
+
+ )}
+
+ Generate
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx
new file mode 100644
index 00000000..da1976bb
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx
@@ -0,0 +1,47 @@
+import { Ban, Sparkles, Undo2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
+
+type CompDocDialogFooterNotRequiredProps = {
+ onUndo: () => void;
+};
+
+export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) {
+ return (
+ <>
+
+ Marked{" "}
+ not required for this work order
+
+
+
+ Undo
+
+
+ Generate
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx
new file mode 100644
index 00000000..b21cbd05
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx
@@ -0,0 +1,94 @@
+import type { RefObject } from "react";
+import type { DocState } from "@/domain/work-orders/types/completion-doc";
+import { CompDocDialogFooterDraftActions } from "./comp-doc-dialog-footer-draft-actions";
+import { CompDocDialogFooterGeneratedActions } from "./comp-doc-dialog-footer-generated-actions";
+import { CompDocDialogFooterNotRequiredActions } from "./comp-doc-dialog-footer-not-required-actions";
+
+type CompDocDialogFooterProps = {
+ docState: DocState;
+ isDocumentsReadOnly: boolean;
+ rowVersion?: string | number | null;
+ isUploading: boolean;
+ signOffAttachment?: string | null;
+ generatedAt: string;
+ pdfInputRef: RefObject;
+ onMarkNotRequired: () => void;
+ onSaveDraft: () => void;
+ onGenerate: () => void;
+ onUndo: () => void;
+ onDownloadPdf: () => void;
+ onUploadPdf: (files: FileList | null) => void;
+};
+
+export function CompDocDialogFooter({
+ docState,
+ isDocumentsReadOnly,
+ rowVersion,
+ isUploading,
+ signOffAttachment,
+ generatedAt,
+ pdfInputRef,
+ onMarkNotRequired,
+ onSaveDraft,
+ onGenerate,
+ onUndo,
+ onDownloadPdf,
+ onUploadPdf,
+}: CompDocDialogFooterProps) {
+ // Exclusive footer branches (prototype: one docState at a time). A signed PDF
+ // on file implies Generated even if local docState has not caught up yet — never
+ // render Draft actions alongside Generated.
+ const showNotRequired = docState === "not-required";
+ const showGenerated =
+ !showNotRequired && (docState === "generated" || Boolean(signOffAttachment));
+ const showDraft = !showNotRequired && !showGenerated && docState === "draft";
+
+ return (
+
+ {
+ onUploadPdf(event.target.files);
+ event.target.value = "";
+ }}
+ />
+ {Boolean(showDraft && !isDocumentsReadOnly) && (
+
+ )}
+ {showGenerated && (
+
+ )}
+ {showNotRequired && (
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx
new file mode 100644
index 00000000..08a9cb20
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx
@@ -0,0 +1,96 @@
+import { FileText, X } from "lucide-react";
+import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
+
+type CompDocDialogHeaderProps = {
+ info: WorkOrderTableRow;
+ cfg: CompletionDocStateConfig;
+ isUploading: boolean;
+ onClose: () => void;
+};
+
+export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
+ return (
+
+
+
+
+
+ Completion Document
+
+
+ WO{" "}
+
+ #{info.woNumber}
+
+ {" · "}
+ {serviceTitle(info)}
+
+
+
+
+
+
+ {cfg.pillLabel}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx
new file mode 100644
index 00000000..bb772f0b
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx
@@ -0,0 +1,48 @@
+import { Download, FileText } from "lucide-react";
+
+type CompDocDialogResourceLinksProps = {
+ templateUrl?: string | null;
+ signOffAttachment?: string | null;
+};
+
+export function CompDocDialogResourceLinks({
+ templateUrl,
+ signOffAttachment,
+}: CompDocDialogResourceLinksProps) {
+ if (!templateUrl && !signOffAttachment) return null;
+
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx
new file mode 100644
index 00000000..f87fbb78
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx
@@ -0,0 +1,132 @@
+import type { MouseEvent } from "react";
+import { Dialog, DialogContent, DialogTitle } from "@mui/material";
+import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
+import { Text } from "@/components/ui/text";
+import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
+import { CompDocDialogHeader } from "./comp-doc-dialog-header";
+import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
+import { CompDocPreview } from "./comp-doc-preview";
+import { CompDocSidebar } from "./comp-doc-sidebar";
+import { useCompDocDialog } from "./use-comp-doc-dialog";
+
+type CompDocDialogProps = {
+ row: WorkOrderTableRow;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ data?: CompDocData;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange?: (date: string) => void | Promise;
+};
+
+export function CompDocDialog({
+ row,
+ open,
+ onOpenChange,
+ data,
+ onSave,
+ onDateCompletedChange,
+}: CompDocDialogProps) {
+ const dialog = useCompDocDialog({
+ row,
+ open,
+ data,
+ onSave,
+ onDateCompletedChange,
+ });
+ const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
+
+ return (
+ ) => e.stopPropagation(),
+ },
+ }}
+ >
+
+
+ Completion Document — WO #{dialog.info.woNumber}
+
+
+ Generate, save or mark not-required the completion document for this work order.
+
+
+
+
handleClose?.()}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx
new file mode 100644
index 00000000..910b7227
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx
@@ -0,0 +1,29 @@
+import { CheckCircle2 } from "lucide-react";
+import { toast } from "react-toastify";
+
+export function showCompDocDraftSavedToast() {
+ toast(
+
+ Draft saved
+
,
+ {
+ position: "bottom-center",
+ autoClose: 3000,
+ hideProgressBar: true,
+ closeButton: false,
+ icon: false,
+ className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
+ },
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx
new file mode 100644
index 00000000..3823f39b
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx
@@ -0,0 +1,51 @@
+import type { CSSProperties } from "react";
+
+const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
+
+const COMP_DOC_INPUT_STYLE: CSSProperties = {
+ fontSize: 13,
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-primary)",
+ background: "var(--color-bg-surface)",
+};
+
+type CompDocEditRowProps = {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ type?: string;
+ readOnly?: boolean;
+};
+
+export function CompDocEditRow({
+ label,
+ value,
+ onChange,
+ type = "text",
+ readOnly = false,
+}: CompDocEditRowProps) {
+ return (
+
+
+ {label}
+
+
onChange(e.target.value)}
+ readOnly={readOnly}
+ className={COMP_DOC_INPUT_CLS}
+ style={{
+ ...COMP_DOC_INPUT_STYLE,
+ ...(readOnly
+ ? {
+ opacity: 0.6,
+ cursor: "default",
+ pointerEvents: "none" as const,
+ }
+ : {}),
+ }}
+ />
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx
new file mode 100644
index 00000000..8e9261e2
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx
@@ -0,0 +1,54 @@
+import type { CSSProperties } from "react";
+
+const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
+
+const COMP_DOC_INPUT_STYLE: CSSProperties = {
+ fontSize: 13,
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-primary)",
+ background: "var(--color-bg-surface)",
+};
+
+const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = {
+ opacity: 0.6,
+ cursor: "default",
+ pointerEvents: "none",
+};
+
+type CompDocEditableRowProps = {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ type?: string;
+ placeholder?: string;
+ disabled?: boolean;
+};
+
+export function CompDocEditableRow({
+ label,
+ value,
+ onChange,
+ type = "text",
+ placeholder = "",
+ disabled = false,
+}: CompDocEditableRowProps) {
+ return (
+
+
+ {label}
+
+
onChange(e.target.value)}
+ readOnly={disabled}
+ placeholder={placeholder}
+ className={COMP_DOC_INPUT_CLS}
+ style={{
+ ...COMP_DOC_INPUT_STYLE,
+ ...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}),
+ }}
+ />
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts
new file mode 100644
index 00000000..f5c0f2ba
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts
@@ -0,0 +1,8 @@
+export const COMP_DOC_FOOTER_BTN_SX = {
+ minHeight: 28,
+ height: 28,
+ fontSize: 12,
+ px: 1.25,
+ py: 0.5,
+ gap: 0.75,
+} as const;
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts
new file mode 100644
index 00000000..f5c0f2ba
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts
@@ -0,0 +1,8 @@
+export const COMP_DOC_FOOTER_BTN_SX = {
+ minHeight: 28,
+ height: 28,
+ fontSize: 12,
+ px: 1.25,
+ py: 0.5,
+ gap: 0.75,
+} as const;
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx
new file mode 100644
index 00000000..ab3cbf04
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx
@@ -0,0 +1,25 @@
+import { Ban } from "lucide-react";
+
+export function CompDocNotRequiredStamp() {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx
new file mode 100644
index 00000000..f6deb5e5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx
@@ -0,0 +1,50 @@
+type CompDocNotesFieldProps = {
+ notes: string;
+ onNotesChange: (value: string) => void;
+ isGenerated: boolean;
+};
+
+export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) {
+ return (
+
+
+ PM Comments{" "}
+
+ — optional, included in the document
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx
new file mode 100644
index 00000000..9df6c499
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx
@@ -0,0 +1,32 @@
+import { CheckCircle2, FileText } from "lucide-react";
+
+type CompDocPreviewBadgeProps = {
+ isGenerated: boolean;
+};
+
+export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
+ return (
+
+
+ {isGenerated ? (
+
+ ) : (
+
+ )}
+ {isGenerated ? "Generated document" : "Preview — not yet generated"}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx
new file mode 100644
index 00000000..0797a44d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx
@@ -0,0 +1,36 @@
+import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
+import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
+import { CompDocCoLine } from "./comp-doc-co-line";
+
+export function CompDocPreviewCompanyHeader() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx
new file mode 100644
index 00000000..4c8c7872
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx
@@ -0,0 +1,58 @@
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { CompDocPreviewCompanyHeader } from "./comp-doc-preview-company-header";
+import { CompDocPreviewProcedureNotes } from "./comp-doc-preview-procedure-notes";
+import { CompDocPreviewServiceInfo } from "./comp-doc-preview-service-info";
+import { CompDocPreviewVendorSafety } from "./comp-doc-preview-vendor-safety";
+
+type CompDocPreviewDocumentProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ notes: string;
+ generatedAt: string;
+ isGenerated: boolean;
+ isNotRequired: boolean;
+};
+
+export function CompDocPreviewDocument({
+ row,
+ site,
+ notes,
+ generatedAt,
+ isGenerated,
+ isNotRequired,
+}: CompDocPreviewDocumentProps) {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header.tsx
new file mode 100644
index 00000000..3fdeb30a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header.tsx
@@ -0,0 +1,36 @@
+import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
+import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
+import { CompDocCoLine } from "./comp-doc-co-line";
+
+export function CompDocPreviewHeader() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx
new file mode 100644
index 00000000..c3ed0ec2
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx
@@ -0,0 +1,25 @@
+import { Ban } from "lucide-react";
+
+export function CompDocPreviewNotRequiredStamp() {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx
new file mode 100644
index 00000000..e0c13e1a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx
@@ -0,0 +1,68 @@
+import { Text } from "@/components/ui/text";
+import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
+import { CompDocProcedureList } from "./comp-doc-procedure-list";
+
+type CompDocPreviewProcedureNotesProps = {
+ pm: string;
+ notes: string;
+};
+
+export function CompDocPreviewProcedureNotes({ pm, notes }: CompDocPreviewProcedureNotesProps) {
+ const proc = procedureFor(pm);
+
+ return (
+ <>
+
+ {proc.heading}
+
+
+ {proc.intro}
+
+
+
+ {Boolean(notes) && (
+ <>
+
+ PM Comments
+
+
+ {notes}
+
+ >
+ )}
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
new file mode 100644
index 00000000..871468fc
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
@@ -0,0 +1,87 @@
+import { Text } from "@/components/ui/text";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
+import { CompDocProcedureList } from "./comp-doc-procedure-list";
+
+type CompDocPreviewProcedureProps = {
+ row: WorkOrderTableRow;
+ notes: string;
+};
+
+export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
+ const proc = procedureFor(row.pm);
+
+ return (
+ <>
+
+ {SAFETY_BULLETS.map((b, i) => (
+
+ {b}
+
+ ))}
+
+
+
+ {proc.heading}
+
+
+ {proc.intro}
+
+
+
+ {notes.length > 0 && (
+ <>
+
+ PM Comments
+
+
+ {notes}
+
+ >
+ )}
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx
new file mode 100644
index 00000000..8dd82d83
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx
@@ -0,0 +1,89 @@
+import { CheckCircle2 } from "lucide-react";
+import { Text } from "@/components/ui/text";
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
+
+type CompDocPreviewServiceInfoProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ isGenerated: boolean;
+ generatedAt: string;
+};
+
+export function CompDocPreviewServiceInfo({
+ row,
+ site,
+ isGenerated,
+ generatedAt,
+}: CompDocPreviewServiceInfoProps) {
+ return (
+ <>
+ {isGenerated && (
+
+ Generated · {generatedAt} · v1
+
+ )}
+
+
+ {serviceTitle(row).toUpperCase()}
+
+
+ WO# {row.woNumber}
+
+
+
+
+ Site ID — {row.site}
+
+
+ Amazon.com Services LLC {row.site}
+
+
+ {site.street},
+
+
+ {site.label}
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx
new file mode 100644
index 00000000..5cf12ae6
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx
@@ -0,0 +1,32 @@
+import { CheckCircle2, FileText } from "lucide-react";
+
+type CompDocPreviewStatusBadgeProps = {
+ isGenerated: boolean;
+};
+
+export function CompDocPreviewStatusBadge({ isGenerated }: CompDocPreviewStatusBadgeProps) {
+ return (
+
+
+ {isGenerated ? (
+
+ ) : (
+
+ )}
+ {isGenerated ? "Generated document" : "Preview — not yet generated"}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-summary.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-summary.tsx
new file mode 100644
index 00000000..c9716b8d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-summary.tsx
@@ -0,0 +1,96 @@
+import { CheckCircle2 } from "lucide-react";
+import { Text } from "@/components/ui/text";
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
+
+type CompDocPreviewSummaryProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ generatedAt: string;
+ isGenerated: boolean;
+};
+
+export function CompDocPreviewSummary({
+ row,
+ site,
+ generatedAt,
+ isGenerated,
+}: CompDocPreviewSummaryProps) {
+ return (
+ <>
+ {isGenerated && (
+
+ Generated · {generatedAt} · v1
+
+ )}
+
+
+ {serviceTitle(row).toUpperCase()}
+
+
+ WO# {row.woNumber}
+
+
+
+
+ Site ID — {row.site}
+
+
+ Amazon.com Services LLC {row.site}
+
+
+ {site.street},
+
+
+ {site.label}
+
+
+
+
+ Vendor / Technician:
+ {row.tech || "—"}
+ {row.company ? ` · ${row.company}` : ""}
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx
new file mode 100644
index 00000000..807eca19
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx
@@ -0,0 +1,35 @@
+import { Text } from "@/components/ui/text";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
+
+type CompDocPreviewVendorSafetyProps = {
+ row: WorkOrderTableRow;
+};
+
+export function CompDocPreviewVendorSafety({ row }: CompDocPreviewVendorSafetyProps) {
+ return (
+ <>
+
+ Vendor / Technician:
+ {row.tech || "—"}
+ {row.company ? ` · ${row.company}` : ""}
+
+
+
+ {SAFETY_BULLETS.map((b, i) => (
+
+ {b}
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
new file mode 100644
index 00000000..df26f1a8
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
@@ -0,0 +1,65 @@
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { CompDocPreviewDocument } from "./comp-doc-preview-document";
+import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp";
+import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge";
+
+type CompDocPreviewProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ notes: string;
+ generatedAt: string;
+ isGenerated: boolean;
+ isNotRequired: boolean;
+};
+
+export function CompDocPreview({
+ row,
+ site,
+ notes,
+ generatedAt,
+ isGenerated,
+ isNotRequired,
+}: CompDocPreviewProps) {
+ return (
+
+
+
+
+
+
+
+ {isNotRequired && }
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx
new file mode 100644
index 00000000..5d9de8b9
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx
@@ -0,0 +1,32 @@
+import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
+
+type CompDocProcedureListProps = {
+ proc: Procedure;
+};
+
+export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
+ return (
+
+ {proc.steps.map((st, i) => (
+
+ {st.text}
+ {st.sub != null && (
+
+ {st.sub.map((x, j) => (
+
+ {x}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx
new file mode 100644
index 00000000..02035224
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx
@@ -0,0 +1,174 @@
+import { Building2, MapPin, Users, Wrench } from "lucide-react";
+import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import {
+ DOC_STATE_CFG,
+ docStatusCardDescription,
+} from "@/domain/work-orders/utils/completion-doc-state";
+import { CompDocDetailRow } from "./comp-doc-detail-row";
+import { CompDocEditRow } from "./comp-doc-edit-row";
+
+type CompDocSidebarProps = {
+ row: WorkOrderTableRow;
+ site: CompDocSiteInfo;
+ docState: DocState;
+ generatedAt: string;
+ notes: string;
+ editDateCompleted: string;
+ amazonRep: string;
+ seaRep: string;
+ isGenerated: boolean;
+ onNotesChange: (value: string) => void;
+ onDateCompletedChange: (value: string) => void;
+};
+
+export function CompDocSidebar({
+ row,
+ site,
+ docState,
+ generatedAt,
+ notes,
+ editDateCompleted,
+ amazonRep,
+ seaRep,
+ isGenerated,
+ onNotesChange,
+ onDateCompletedChange,
+}: CompDocSidebarProps) {
+ const displayState: DocState =
+ isGenerated && docState !== "not-required" ? "generated" : docState;
+ const cfg = DOC_STATE_CFG[displayState];
+ const StateIcon = cfg.icon;
+
+ return (
+
+
+
+
+
+
+
+
+ {cfg.cardTitle}
+
+
+ {docStatusCardDescription(displayState, generatedAt)}
+
+
+
+
+
+
+ Work Order Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PM Comments{" "}
+
+ — optional, included in the document
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-status-card.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-status-card.tsx
new file mode 100644
index 00000000..b2aaf7d7
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-status-card.tsx
@@ -0,0 +1,61 @@
+import type { DocState } from "@/domain/work-orders/types/completion-doc";
+import {
+ DOC_STATE_CFG,
+ docStatusCardDescription,
+} from "@/domain/work-orders/utils/completion-doc-state";
+
+type CompDocStatusCardProps = {
+ docState: DocState;
+ generatedAt: string;
+};
+
+export function CompDocStatusCard({ docState, generatedAt }: CompDocStatusCardProps) {
+ const cfg = DOC_STATE_CFG[docState];
+ const StateIcon = cfg.icon;
+
+ return (
+
+
+
+
+
+
+ {cfg.cardTitle}
+
+
+ {docStatusCardDescription(docState, generatedAt)}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts
new file mode 100644
index 00000000..d189971f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts
@@ -0,0 +1,59 @@
+import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
+import type { CompDocData, DocState } from "@/domain/work-orders/types/completion-doc";
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+import { docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
+
+type UseCompDocDialogSeedArgs = {
+ open: boolean;
+ infoId: string | number;
+ infoDocStatus: CompletionDocStatus;
+ infoCompletedDate?: string | null;
+ completionDocStatus?: CompletionDocStatus | null;
+ data?: CompDocData;
+ setNotes: Dispatch>;
+ setDocState: Dispatch>;
+ setGeneratedAt: Dispatch>;
+ setEditDateCompleted: Dispatch>;
+};
+
+/** Prototype seeds once per open/WO session; detail refetches must not wipe edits. */
+export function useCompDocDialogSeed({
+ open,
+ infoId,
+ infoDocStatus,
+ infoCompletedDate,
+ completionDocStatus,
+ data,
+ setNotes,
+ setDocState,
+ setGeneratedAt,
+ setEditDateCompleted,
+}: UseCompDocDialogSeedArgs) {
+ const seedKeyRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) {
+ seedKeyRef.current = null;
+ return;
+ }
+ const key = String(infoId);
+ if (seedKeyRef.current === key) return;
+ seedKeyRef.current = key;
+ setNotes(data?.notes ?? "");
+ setDocState(docStateOf(completionDocStatus || infoDocStatus));
+ setGeneratedAt(data?.generatedAt ?? "");
+ setEditDateCompleted(infoCompletedDate || "");
+ }, [
+ open,
+ infoId,
+ infoDocStatus,
+ infoCompletedDate,
+ completionDocStatus,
+ data?.notes,
+ data?.generatedAt,
+ setNotes,
+ setDocState,
+ setGeneratedAt,
+ setEditDateCompleted,
+ ]);
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx
new file mode 100644
index 00000000..365cf856
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx
@@ -0,0 +1,179 @@
+import { useMemo, useRef, useState } from "react";
+import { toast } from "react-toastify";
+import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
+import type {
+ CompDocData,
+ CompDocSaveStatus,
+ DocFields,
+ DocState,
+} from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
+import { DOC_STATE_CFG } from "@/domain/work-orders/utils/completion-doc-state";
+import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
+import { useCompDocDialogSeed } from "./use-comp-doc-dialog-seed";
+import { useCompDocNotRequiredActions } from "./use-comp-doc-not-required-actions";
+import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
+import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
+
+type UseCompDocDialogArgs = {
+ row: WorkOrderTableRow;
+ open: boolean;
+ data?: CompDocData;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange?: (date: string) => void | Promise;
+};
+
+function notifyDraftSaved() {
+ toast(
+
+ Draft saved
+
,
+ {
+ position: "bottom-center",
+ autoClose: 3000,
+ hideProgressBar: true,
+ closeButton: false,
+ icon: false,
+ className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
+ },
+ );
+}
+
+export function useCompDocDialog({
+ row,
+ open,
+ data,
+ onSave,
+ onDateCompletedChange,
+}: UseCompDocDialogArgs) {
+ const locId = String(row.locationId || "");
+ const { data: locationDetail } = useLocationDetail(locId || undefined);
+ const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
+ const uploadCompletionMutation = useUploadCompletionDoc(row.id);
+ const pdfInputRef = useRef(null);
+ const [isFinalizing, setIsFinalizing] = useState(false);
+
+ const info = boardDetail?.info ?? row;
+ const completion = boardDetail?.completion;
+ const templateUrl = completion?.template?.templateUrl;
+ const signOffAttachment = completion?.signOffAttachment;
+ const rowVersion = info.rowVersion || row.rowVersion;
+ const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
+ const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
+
+ const [notes, setNotes] = useState("");
+ const [docState, setDocState] = useState("draft");
+ const [generatedAt, setGeneratedAt] = useState("");
+ const [editDateCompleted, setEditDateCompleted] = useState("");
+
+ useCompDocDialogSeed({
+ open,
+ infoId: info.id,
+ infoDocStatus: info.docStatus,
+ infoCompletedDate: info.completedDate,
+ completionDocStatus: completion?.docStatus,
+ data,
+ setNotes,
+ setDocState,
+ setGeneratedAt,
+ setEditDateCompleted,
+ });
+
+ const amazonRep = info.pocName || "";
+ const seaRep = info.tech || "";
+ const cfg = DOC_STATE_CFG[docState];
+ const isNotRequired = docState === "not-required";
+ const isGenerated = docState === "generated" || Boolean(signOffAttachment);
+
+ const docFields: DocFields = {
+ street: site.street,
+ city: site.city,
+ state: site.state,
+ zip: site.zip,
+ dateCompleted: editDateCompleted,
+ amazonRep,
+ seaRep,
+ };
+
+ const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
+ useCompDocPersistFlow({
+ workOrderId: row.id,
+ notes,
+ generatedAt,
+ editDateCompleted,
+ currentCompletedDate: info.completedDate || "",
+ onSave,
+ onDateCompletedChange,
+ onGenerated: (ts) => {
+ setGeneratedAt(ts);
+ setDocState("generated");
+ },
+ onDraftSaved: notifyDraftSaved,
+ });
+
+ const { handleMarkNotRequired, handleUndo } = useCompDocNotRequiredActions({
+ docState,
+ setDocState,
+ persist,
+ refreshAuthoritativeState,
+ setIsFinalizing,
+ });
+
+ const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
+ info,
+ signOffAttachment,
+ rowVersion,
+ notes,
+ generatedAt,
+ docFields,
+ uploadCompletionMutation,
+ persist,
+ syncDateIfChanged,
+ setGeneratedAt,
+ markGenerated: () => setDocState("generated"),
+ onPersistFailure: refreshAuthoritativeState,
+ onFinalizingChange: setIsFinalizing,
+ });
+
+ return {
+ info,
+ templateUrl,
+ signOffAttachment,
+ rowVersion,
+ isDocumentsReadOnly,
+ site,
+ notes,
+ setNotes,
+ docState,
+ generatedAt,
+ editDateCompleted,
+ setEditDateCompleted,
+ amazonRep,
+ seaRep,
+ cfg,
+ isNotRequired,
+ isGenerated,
+ isUploading: uploadCompletionMutation.isPending || isFinalizing,
+ pdfInputRef,
+ handleMarkNotRequired,
+ handleUndo,
+ handleSaveDraft,
+ handleGenerate,
+ handleDownloadPdf,
+ handleUploadPdf,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts
new file mode 100644
index 00000000..4159b926
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts
@@ -0,0 +1,58 @@
+import { toast } from "react-toastify";
+import type {
+ CompDocData,
+ CompDocSaveStatus,
+ DocState,
+} from "@/domain/work-orders/types/completion-doc";
+
+type PersistFn = (data: Partial, status: CompDocSaveStatus) => void | Promise;
+
+type UseCompDocNotRequiredActionsArgs = {
+ docState: DocState;
+ setDocState: (state: DocState) => void;
+ persist: PersistFn;
+ refreshAuthoritativeState: () => void;
+ setIsFinalizing: (value: boolean) => void;
+};
+
+export function useCompDocNotRequiredActions({
+ docState,
+ setDocState,
+ persist,
+ refreshAuthoritativeState,
+ setIsFinalizing,
+}: UseCompDocNotRequiredActionsArgs) {
+ async function handleMarkNotRequired() {
+ const previous = docState;
+ setDocState("not-required");
+ setIsFinalizing(true);
+ try {
+ await persist({}, "NN");
+ } catch (error) {
+ setDocState(previous);
+ refreshAuthoritativeState();
+ toast.error(
+ error instanceof Error ? error.message : "Failed to mark document as not required",
+ );
+ } finally {
+ setIsFinalizing(false);
+ }
+ }
+
+ async function handleUndo() {
+ const previous = docState;
+ setDocState("draft");
+ setIsFinalizing(true);
+ try {
+ await persist({}, "No");
+ } catch (error) {
+ setDocState(previous);
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
+ } finally {
+ setIsFinalizing(false);
+ }
+ }
+
+ return { handleMarkNotRequired, handleUndo };
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts
new file mode 100644
index 00000000..5d3b6475
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts
@@ -0,0 +1,98 @@
+import { toast } from "react-toastify";
+import type {
+ CompDocData,
+ CompDocSaveStatus,
+ DocFields,
+} from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
+import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
+import {
+ formatMediaSizeLimit,
+ isWorkOrderFileTooLarge,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+
+type UseCompDocPdfActionsArgs = {
+ info: WorkOrderTableRow;
+ signOffAttachment?: string | null;
+ rowVersion?: string;
+ notes: string;
+ generatedAt: string;
+ docFields: DocFields;
+ uploadCompletionMutation: ReturnType;
+ persist: (extra: Partial, status: CompDocSaveStatus) => Promise;
+ syncDateIfChanged: () => Promise;
+ setGeneratedAt: (value: string) => void;
+ markGenerated: () => void;
+ onPersistFailure?: () => void;
+ onFinalizingChange?: (finalizing: boolean) => void;
+};
+
+export function useCompDocPdfActions({
+ info,
+ signOffAttachment,
+ rowVersion,
+ notes,
+ generatedAt,
+ docFields,
+ uploadCompletionMutation,
+ persist,
+ syncDateIfChanged,
+ setGeneratedAt,
+ markGenerated,
+ onPersistFailure,
+ onFinalizingChange,
+}: UseCompDocPdfActionsArgs) {
+ function handleDownloadPdf() {
+ if (signOffAttachment) {
+ window.open(signOffAttachment, "_blank", "noopener,noreferrer");
+ return;
+ }
+ printDoc(buildDocHtml(info, { notes, generatedAt }, docFields));
+ }
+
+ function handleUploadPdf(files: FileList | null) {
+ const file = files?.[0];
+ if (!file) return;
+ if (isWorkOrderFileTooLarge(file)) {
+ toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
+ return;
+ }
+ if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
+ toast.error("Please upload a PDF file.");
+ return;
+ }
+ if (!rowVersion) {
+ toast.error("Missing work order version. Refresh and try again.");
+ return;
+ }
+ uploadCompletionMutation.mutate(
+ { file, workOrderVersion: rowVersion },
+ {
+ onSuccess: () => {
+ // Keep close disabled across the gap between upload settle and docStatus persist.
+ onFinalizingChange?.(true);
+ void (async () => {
+ const ts = fmtDocNow();
+ try {
+ await syncDateIfChanged();
+ await persist({ generatedAt: ts }, "Yes");
+ setGeneratedAt(ts);
+ markGenerated();
+ } catch (error) {
+ onPersistFailure?.();
+ toast.error(
+ error instanceof Error ? error.message : "Failed to finalize completion document",
+ );
+ } finally {
+ onFinalizingChange?.(false);
+ }
+ })();
+ },
+ },
+ );
+ }
+
+ return { handleDownloadPdf, handleUploadPdf };
+}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-persist-flow.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-persist-flow.ts
new file mode 100644
index 00000000..57a89c17
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-persist-flow.ts
@@ -0,0 +1,84 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "react-toastify";
+import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
+import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+type UseCompDocPersistFlowArgs = {
+ workOrderId: string | number;
+ notes: string;
+ generatedAt: string;
+ editDateCompleted: string;
+ currentCompletedDate: string;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange?: (date: string) => void | Promise;
+ onGenerated: (generatedAt: string) => void;
+ onDraftSaved: () => void;
+};
+
+export function useCompDocPersistFlow({
+ workOrderId,
+ notes,
+ generatedAt,
+ editDateCompleted,
+ currentCompletedDate,
+ onSave,
+ onDateCompletedChange,
+ onGenerated,
+ onDraftSaved,
+}: UseCompDocPersistFlowArgs) {
+ const queryClient = useQueryClient();
+
+ const refreshAuthoritativeState = () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.workOrders.boardDetail(workOrderId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.workOrders.detail(workOrderId),
+ });
+ };
+
+ const persist = async (extra: Partial, status: CompDocSaveStatus) => {
+ await onSave({ notes, generatedAt, ...extra }, status);
+ };
+
+ const syncDateIfChanged = async () => {
+ if (editDateCompleted !== currentCompletedDate) {
+ await onDateCompletedChange?.(editDateCompleted);
+ }
+ };
+
+ async function handleSaveDraft() {
+ try {
+ // Prerequisites first; docStatus last.
+ await syncDateIfChanged();
+ await persist({}, "No");
+ onDraftSaved();
+ } catch (error) {
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to save draft");
+ }
+ }
+
+ async function handleGenerate() {
+ const ts = fmtDocNow();
+ try {
+ // Prerequisites first; only mark Generated after completedDate persists.
+ await syncDateIfChanged();
+ await persist({ generatedAt: ts }, "Yes");
+ onGenerated(ts);
+ } catch (error) {
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to generate document");
+ }
+ }
+
+ return {
+ persist,
+ syncDateIfChanged,
+ refreshAuthoritativeState,
+ handleSaveDraft,
+ handleGenerate,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/detail/audit-timeline.tsx b/src/app/(protected)/workorders/_components/detail/audit-timeline.tsx
new file mode 100644
index 00000000..2e646ab5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/audit-timeline.tsx
@@ -0,0 +1,67 @@
+import { useMemo } from "react";
+import { Box, Typography } from "@mui/material";
+import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
+import { formatDateTime } from "@/lib/time-utils";
+
+type AuditTimelineProps = {
+ entries: WorkOrderAuditEntry[];
+ emptyMessage?: string;
+ maxHeightClass?: string;
+};
+
+export function AuditTimeline({
+ entries,
+ emptyMessage = "No changes recorded.",
+ maxHeightClass = "max-h-72",
+}: AuditTimelineProps) {
+ const sorted = useMemo(
+ () =>
+ [...entries].sort(
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+ ),
+ [entries],
+ );
+
+ if (!sorted.length) {
+ return {emptyMessage} ;
+ }
+
+ return (
+
+ {sorted.map((entry) => (
+
+
+
+ {entry.userName || "System"}
+
+
+ {entry.createdAt ? formatDateTime(entry.createdAt) : ""}
+
+
+
+ {entry.action ? (
+ entry.action
+ ) : (
+ <>
+ {entry.fieldName} {entry.oldValue} → {entry.newValue}
+ >
+ )}
+
+ {Boolean(entry.action && entry.fieldName) && (
+
+ {entry.fieldName}
+ {entry.oldValue || entry.newValue
+ ? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}`
+ : ""}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx
new file mode 100644
index 00000000..57c72270
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx
@@ -0,0 +1,164 @@
+import { Settings } from "lucide-react";
+import { Text } from "@/components/ui/text";
+import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import {
+ getAvatarColorFromName,
+ getDispatcherAvatarColor,
+ getInitialsFromName,
+} from "@/domain/work-orders/utils/dispatcher-avatar-color";
+import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
+
+function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
+ if (entry.type === "system") return true;
+ if (entry.type === "manual") return false;
+ const name = entry.userName?.trim();
+ return !name || name.toLowerCase() === "system";
+}
+
+function formatClock(date: Date): string {
+ return date.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * Manual (image): `· 9:48 AM`
+ * System (image): `05/13 · 8:00 AM`
+ * Prefer API display strings that already include · / AM|PM.
+ */
+function formatAuditTime(value: string, isSystem: boolean): string {
+ if (!value) return "";
+ if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value;
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+
+ const timePart = formatClock(date);
+ if (isSystem) {
+ const datePart = date.toLocaleDateString("en-US", {
+ month: "2-digit",
+ day: "2-digit",
+ });
+ return `${datePart} · ${timePart}`;
+ }
+ return `· ${timePart}`;
+}
+
+type SlideOverAuditListProps = {
+ entries: WorkOrderAuditEntry[];
+ users?: WorkOrderUser[];
+};
+
+export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListProps) {
+ return (
+
+
+ Audit Log
+
+
+ {entries.length === 0 ? (
+
+ No changes recorded
+
+ ) : (
+ entries.map((entry, index) => {
+ const isSys = isSystemEntry(entry);
+ const dispatcher = users.find(
+ (user) =>
+ entry.dispatcherId != null && String(user.id) === String(entry.dispatcherId),
+ );
+ const name =
+ dispatcher?.name ||
+ entry.userName?.trim() ||
+ (entry.dispatcherId ? String(entry.dispatcherId) : "Unknown");
+ const color = dispatcher
+ ? getDispatcherAvatarColor(dispatcher)
+ : getAvatarColorFromName(name);
+ const initials = dispatcher
+ ? getInitialsFromName(dispatcher.name)
+ : getInitialsFromName(name);
+ const action = formatBoardAuditAction(entry);
+
+ return (
+
+ {isSys ? (
+
+
+
+ ) : (
+
+ {initials}
+
+ )}
+
+ {isSys ? (
+
+ System
+ —
+ {action}
+
+ ) : (
+
+
+ {name}
+
+ ·
+ {action}
+
+ )}
+
+ {formatAuditTime(entry.createdAt, isSys)}
+
+
+
+ );
+ })
+ )}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx
new file mode 100644
index 00000000..82b03b26
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx
@@ -0,0 +1,143 @@
+import { AlertTriangle, Ban, Check, Pencil } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+
+type SlideOverFooterProps = {
+ isCancelled: boolean;
+ editing: boolean;
+ isDirty: boolean;
+ showUnsaved: boolean;
+ saving?: boolean;
+ onCancelWO: () => void;
+ onCancelEdit: () => void;
+ onClose: () => void;
+ onEdit: () => void;
+ onSave: () => void;
+ onDiscard: () => void;
+ onKeepEditing: () => void;
+};
+
+export function SlideOverFooter({
+ isCancelled,
+ editing,
+ isDirty,
+ showUnsaved,
+ saving,
+ onCancelWO,
+ onCancelEdit,
+ onClose,
+ onEdit,
+ onSave,
+ onDiscard,
+ onKeepEditing,
+}: SlideOverFooterProps) {
+ if (showUnsaved) {
+ return (
+
+
+
+ You have unsaved changes.
+
+
+
+ Discard
+
+
+
+ Save
+
+
+ Keep editing
+
+
+
+ );
+ }
+
+ if (isCancelled) {
+ return (
+
+
+
+ This work order has been cancelled and is read-only.
+
+
+ );
+ }
+
+ return (
+
+
+
+ Cancel WO
+
+
+
+
{
+ e.currentTarget.style.color = "var(--color-text-primary)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.color = "var(--color-text-muted)";
+ }}
+ >
+ Cancel
+
+
+ {editing ? (
+
+
+ Save
+
+ ) : (
+
+
+ Edit WO
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx
new file mode 100644
index 00000000..0302625e
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx
@@ -0,0 +1,106 @@
+import { CircularProgress } from "@mui/material";
+import { X } from "lucide-react";
+import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
+import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
+import { Text } from "@/components/ui/text";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+
+type SlideOverHeaderProps = {
+ row: WorkOrderTableRow;
+ isCancelled: boolean;
+ isFetching?: boolean;
+ closeDisabled?: boolean;
+ onClose: () => void;
+};
+
+export function SlideOverHeader({
+ row,
+ isCancelled,
+ isFetching = false,
+ closeDisabled = false,
+ onClose,
+}: SlideOverHeaderProps) {
+ return (
+
+
+
+ #{row.woNumber || "—"}
+
+
+ {isCancelled && (
+
+ Cancelled
+
+ )}
+
+ {Boolean(row.site) && (
+
+ {row.site}
+
+ )}
+
+ {row.isPastDue ? (
+
+
+ Past Due
+
+ ) : row.status ? (
+
+ ) : null}
+
+ {Boolean(row.type) && }
+
+
+
+ {isFetching && }
+ {
+ if (!closeDisabled) {
+ e.currentTarget.style.background = "var(--color-bg-muted)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = "transparent";
+ }}
+ >
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx
new file mode 100644
index 00000000..38942db6
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from "react";
+
+type SlideOverInfoRowProps = {
+ label: string;
+ children: ReactNode;
+};
+
+export function SlideOverInfoRow({ label, children }: SlideOverInfoRowProps) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx
new file mode 100644
index 00000000..1b8200e0
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx
@@ -0,0 +1,11 @@
+import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
+import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+
+type SlideOverAuditTabProps = {
+ entries: WorkOrderAuditEntry[];
+ users?: WorkOrderUser[];
+};
+
+export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) {
+ return ;
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx
new file mode 100644
index 00000000..1d13f890
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx
@@ -0,0 +1,265 @@
+import { useState } from "react";
+import { Pencil } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
+import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
+import {
+ getAvatarColorFromName,
+ getInitialsFromName,
+} from "@/domain/work-orders/utils/dispatcher-avatar-color";
+
+function formatCommentTime(value: string): string {
+ if (!value) return "";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+const textareaStyle = {
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-primary)",
+ background: "var(--color-bg-surface)",
+} as const;
+
+type SlideOverCommentItemProps = {
+ comment: WorkOrderComment;
+ canEdit: boolean;
+ isEditing: boolean;
+ draftText: string;
+ isUpdatePending: boolean;
+ onStartEdit: () => void;
+ onDraftChange: (value: string) => void;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+};
+
+function SlideOverCommentItem({
+ comment,
+ canEdit,
+ isEditing,
+ draftText,
+ isUpdatePending,
+ onStartEdit,
+ onDraftChange,
+ onCancelEdit,
+ onSaveEdit,
+}: SlideOverCommentItemProps) {
+ const author = comment.userName || comment.commenter || "Unknown";
+ const color = getAvatarColorFromName(author);
+ const initials = getInitialsFromName(author);
+
+ return (
+
+
+ {initials}
+
+
+
+
+
+ {author}
+
+
+ {formatCommentTime(comment.createdDate)}
+
+
+ {canEdit && !isEditing && (
+
+
+
+ )}
+
+
+ {isEditing ? (
+
+ ) : (
+
+ {comment.commenttext}
+
+ )}
+
+
+ );
+}
+
+type SlideOverCommentsTabProps = {
+ comments: WorkOrderComment[];
+ isReadOnly: boolean;
+ isPending: boolean;
+ currentUserId?: string | number;
+ isUpdatePending?: boolean;
+ onSubmit: (text: string, options?: { onSuccess?: () => void }) => void;
+ onUpdateComment?: (
+ commentId: string | number,
+ text: string,
+ options?: { onSuccess?: () => void },
+ ) => void;
+};
+
+export function SlideOverCommentsTab({
+ comments,
+ isReadOnly,
+ isPending,
+ currentUserId,
+ isUpdatePending = false,
+ onSubmit,
+ onUpdateComment,
+}: SlideOverCommentsTabProps) {
+ const [commentText, setCommentText] = useState("");
+ const [editingCommentId, setEditingCommentId] = useState(null);
+ const [draftText, setDraftText] = useState("");
+
+ const handlePost = () => {
+ const text = commentText.trim();
+ if (!text || isReadOnly || isPending) return;
+ onSubmit(text, { onSuccess: () => setCommentText("") });
+ };
+
+ const startEdit = (comment: WorkOrderComment) => {
+ setEditingCommentId(comment.id);
+ setDraftText(comment.commenttext);
+ };
+
+ const cancelEdit = () => {
+ setEditingCommentId(null);
+ setDraftText("");
+ };
+
+ const saveEdit = () => {
+ if (editingCommentId == null || !onUpdateComment) return;
+ const text = draftText.trim();
+ if (!text || isUpdatePending) return;
+ onUpdateComment(editingCommentId, text, {
+ onSuccess: () => {
+ setEditingCommentId(null);
+ setDraftText("");
+ },
+ });
+ };
+
+ return (
+
+ {!isReadOnly && (
+ <>
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx
new file mode 100644
index 00000000..51c262be
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx
@@ -0,0 +1,92 @@
+import type { RefObject } from "react";
+import { Ban, CheckCircle2, FileText, Upload } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+
+type SlideOverCompletionActionsProps = {
+ docStatus: CompletionDocStatus;
+ rowVersion?: string | number | null;
+ completionPending: boolean;
+ hasUploadHandler: boolean;
+ pdfInputRef: RefObject;
+ onPdfSelected: (files: FileList | null) => void;
+ onOpenCompletionDoc: () => void;
+};
+
+function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) {
+ if (docStatus === "Yes") {
+ return (
+ <>
+
+ View / Edit Document
+ >
+ );
+ }
+ if (docStatus === "NN") {
+ return (
+ <>
+
+ Document Not Required — Review
+ >
+ );
+ }
+ return (
+ <>
+
+ Open completion document
+ >
+ );
+}
+
+export function SlideOverCompletionActions({
+ docStatus,
+ rowVersion,
+ completionPending,
+ hasUploadHandler,
+ pdfInputRef,
+ onPdfSelected,
+ onOpenCompletionDoc,
+}: SlideOverCompletionActionsProps) {
+ return (
+
+ {hasUploadHandler && (
+ <>
+ pdfInputRef.current?.click()}
+ startIcon={!completionPending ? : undefined}
+ >
+ Upload signed PDF
+
+ {
+ onPdfSelected(event.target.files);
+ event.target.value = "";
+ }}
+ />
+ {!rowVersion && (
+
+ Waiting for work order version before upload is available.
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
new file mode 100644
index 00000000..24e468d1
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
@@ -0,0 +1,52 @@
+import { Image as ImageIcon } from "lucide-react";
+import {
+ MediaUploader,
+ type MediaUploadRequest,
+} from "@/app/(protected)/workorders/_components/media/media-uploader";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+
+type SlideOverCompletionMediaProps = {
+ isReadOnly: boolean;
+ media: MediaFile[];
+ mediaPending: boolean;
+ onUploadMedia?: (requests: MediaUploadRequest[]) => void;
+ onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
+ onRemoveMedia?: (id: string) => void;
+ onRetryMedia?: (localId: string) => void;
+ onCancelMedia?: (localId: string) => void;
+};
+
+export function SlideOverCompletionMedia({
+ isReadOnly,
+ media,
+ mediaPending,
+ onUploadMedia,
+ onCategorizeMedia,
+ onRemoveMedia,
+ onRetryMedia,
+ onCancelMedia,
+}: SlideOverCompletionMediaProps) {
+ return (
+
+
+
+
+ Photos & Videos
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx
new file mode 100644
index 00000000..e9310484
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx
@@ -0,0 +1,45 @@
+import { CheckCircle2, FileText } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+
+type SlideOverCompletionReadonlyStatusProps = {
+ docStatus: CompletionDocStatus;
+ onOpenCompletionDoc: () => void;
+};
+
+export function SlideOverCompletionReadonlyStatus({
+ docStatus,
+ onOpenCompletionDoc,
+}: SlideOverCompletionReadonlyStatusProps) {
+ if (docStatus === "Yes") {
+ return (
+
+
+ View Document
+
+ );
+ }
+
+ return (
+
+
+
+ {docStatus === "NN"
+ ? "No completion document was required for this work order."
+ : "No completion document was generated."}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx
new file mode 100644
index 00000000..a0e51bf5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx
@@ -0,0 +1,35 @@
+import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
+
+type SlideOverCompletionSignoffsProps = {
+ completion?: WorkOrderBoardCompletion;
+};
+
+export function SlideOverCompletionSignoffs({ completion }: SlideOverCompletionSignoffsProps) {
+ if (!completion || completion.dispatchSignoffs.length === 0) return null;
+
+ return (
+
+
+ Dispatch sign-offs
+
+ {completion.dispatchSignoffs.map((signoff, index) => (
+
+ {signoff.signoffType || "Sign-off"}
+ {signoff.name ? ` — ${signoff.name}` : ""}
+ {signoff.signedAt ? ` · ${signoff.signedAt}` : ""}
+
+ ))}
+
+ );
+}
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
new file mode 100644
index 00000000..290ec603
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
@@ -0,0 +1,143 @@
+import { useRef } from "react";
+import { Download, FileText } from "lucide-react";
+import { toast } from "react-toastify";
+import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+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 {
+ formatMediaSizeLimit,
+ isWorkOrderFileTooLarge,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+import { SlideOverCompletionActions } from "./slide-over-completion-actions";
+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;
+ completion?: WorkOrderBoardCompletion;
+ media: MediaFile[];
+ mediaPending?: boolean;
+ completionPending?: boolean;
+ onUploadMedia?: (requests: MediaUploadRequest[]) => void;
+ onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
+ onRemoveMedia?: (id: string) => void;
+ onRetryMedia?: (localId: string) => void;
+ onCancelMedia?: (localId: string) => void;
+ onUploadCompletionPdf?: (file: File) => void;
+ onOpenCompletionDoc: () => void;
+};
+
+export function SlideOverCompletionTab({
+ row,
+ isReadOnly,
+ completion,
+ media,
+ mediaPending = false,
+ completionPending = false,
+ onUploadMedia,
+ onCategorizeMedia,
+ onRemoveMedia,
+ onRetryMedia,
+ onCancelMedia,
+ onUploadCompletionPdf,
+ onOpenCompletionDoc,
+}: SlideOverCompletionTabProps) {
+ const pdfInputRef = useRef(null);
+ const docStatus = completion?.docStatus || row.docStatus;
+ const templateUrl = completion?.template?.templateUrl;
+ const signOffAttachment = completion?.signOffAttachment;
+
+ if (isMediaWO(row.type)) {
+ return (
+
+ );
+ }
+
+ const handlePdfSelected = (files: FileList | null) => {
+ const file = files?.[0];
+ if (!file || !onUploadCompletionPdf) return;
+ if (isWorkOrderFileTooLarge(file)) {
+ toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
+ return;
+ }
+ if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
+ toast.error("Please upload a PDF file.");
+ return;
+ }
+ onUploadCompletionPdf(file);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx
new file mode 100644
index 00000000..5282cda2
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx
@@ -0,0 +1,44 @@
+import { Image as ImageIcon } from "lucide-react";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+
+function isMediaWO(type: WOType | ""): boolean {
+ return type === "Emergency" || type === "Reactive";
+}
+
+const DOC_BADGE_STYLE: Record<"Yes" | "No" | "NN", { bg: string; text: string }> = {
+ Yes: { bg: "#F0FDF4", text: "#166534" },
+ No: { bg: "#FFF1F2", text: "#BE123C" },
+ NN: { bg: "#F3F4F6", text: "#6B7280" },
+};
+
+export function DocBadge({ row }: { row: WorkOrderTableRow }) {
+ if (isMediaWO(row.type)) {
+ return (
+
+
+ {row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"}
+
+ );
+ }
+
+ const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No";
+ const style = DOC_BADGE_STYLE[key];
+ const label = key === "NN" ? "NN" : key;
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx
new file mode 100644
index 00000000..b4c93318
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx
@@ -0,0 +1,163 @@
+import { useRef } from "react";
+import {
+ File as FileIcon,
+ FileText,
+ Film,
+ Image as ImageIcon,
+ Paperclip,
+ Upload,
+ X,
+} from "lucide-react";
+import { toast } from "react-toastify";
+import { Text } from "@/components/ui/text";
+import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
+import {
+ canDeleteWorkOrderMedia,
+ formatMediaSizeLimit,
+ isWorkOrderFileTooLarge,
+ isWorkOrderMediaTypeAllowed,
+ workOrderMediaAcceptAttribute,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+
+const FILETYPE_ICON: Record = {
+ image: ImageIcon,
+ video: Film,
+ pdf: FileText,
+ doc: FileIcon,
+ other: Paperclip,
+};
+
+type SlideOverExtrasTabProps = {
+ uploads: MediaFile[];
+ isReadOnly: boolean;
+ pending?: boolean;
+ onUpload: (files: File[]) => void;
+ onRemove: (id: string) => void;
+};
+
+export function SlideOverExtrasTab({
+ uploads,
+ isReadOnly,
+ pending = false,
+ onUpload,
+ onRemove,
+}: SlideOverExtrasTabProps) {
+ const inputRef = useRef(null);
+
+ const addFiles = (files: FileList | null) => {
+ if (isReadOnly || pending || !files || files.length === 0) return;
+ const accepted: File[] = [];
+ for (const file of Array.from(files)) {
+ if (!isWorkOrderMediaTypeAllowed(file)) {
+ toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
+ continue;
+ }
+ if (isWorkOrderFileTooLarge(file)) {
+ toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
+ continue;
+ }
+ accepted.push(file);
+ }
+ if (accepted.length > 0) {
+ onUpload(accepted);
+ }
+ };
+
+ return (
+
+
+
+
+ Extra Documents
+
+
+
+ {!isReadOnly && (
+ <>
+
inputRef.current?.click()}
+ onDragOver={(event) => event.preventDefault()}
+ onDrop={(event) => {
+ event.preventDefault();
+ addFiles(event.dataTransfer.files);
+ }}
+ className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
+ style={{
+ minHeight: 100,
+ border: "1px dashed var(--color-border)",
+ borderRadius: 8,
+ }}
+ >
+
+
+ {pending ? "Uploading…" : "Upload documents, photos or videos"}
+
+
+ Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
+
+
+
{
+ addFiles(event.target.files);
+ event.target.value = "";
+ }}
+ />
+ >
+ )}
+
+ {uploads.length === 0 ? (
+
+ No extra documents yet.
+
+ ) : (
+
+ {uploads.map((file) => {
+ const ItemIcon = FILETYPE_ICON[file.fileType];
+ const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
+ return (
+
+
+
+ {file.name}
+
+ {showDelete && (
+
onRemove(file.id)}
+ className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
+ style={{ color: "var(--color-text-muted)" }}
+ >
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx
new file mode 100644
index 00000000..8d7bc8ba
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx
@@ -0,0 +1,28 @@
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
+
+type SlideOverInfoTabAssignedFieldProps = {
+ dispatcher?: WorkOrderUser;
+ dispatcherName?: string;
+};
+
+export function SlideOverInfoTabAssignedField({
+ dispatcher,
+ dispatcherName,
+}: SlideOverInfoTabAssignedFieldProps) {
+ if (!dispatcher && !dispatcherName) {
+ return — ;
+ }
+
+ return (
+
+ {dispatcher != null && (
+
+ )}
+ {dispatcher?.name || dispatcherName}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx
new file mode 100644
index 00000000..ec9c3479
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx
@@ -0,0 +1,92 @@
+import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
+import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
+import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
+import type { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
+import { DocBadge } from "./slide-over-doc-badge";
+import { SlideOverInfoTabAssignedField } from "./slide-over-info-tab-assigned-field";
+import { SlideOverInfoTabStatusField } from "./slide-over-info-tab-status-field";
+import { SlideOverInfoTabVendorField } from "./slide-over-info-tab-vendor-field";
+
+type SlideOverInfoTabDisplayViewProps = {
+ source: WorkOrderTableRow;
+ isCancelled: boolean;
+ dispatcher?: WorkOrderUser;
+ vendors: VendorDropdownItem[];
+};
+
+export function SlideOverInfoTabDisplayView({
+ source,
+ isCancelled,
+ dispatcher,
+ vendors,
+}: SlideOverInfoTabDisplayViewProps) {
+ const appt = parseApptTime(source.apptTime);
+ const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
+
+ return (
+
+
+ {source.woNumber || "—"}
+
+
+
+ {source.site || "—"}
+
+
+
+ {source.type ? (
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+ {source.scheduleWeekOnly && source.targetWeek
+ ? `Week of ${source.targetWeek}`
+ : source.scheduledOn || "—"}
+
+
+
+ {source.dueDate || "—"}
+
+
+ {apptDisplay}
+
+
+ {source.pm ? (
+
+
+ {source.pm}
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {source.pocName || "—"}
+ {source.pocPhone ? ` · ${source.pocPhone}` : ""}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx
new file mode 100644
index 00000000..7eaf94f3
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx
@@ -0,0 +1,160 @@
+import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
+import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
+import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
+import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
+import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
+import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
+import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
+import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
+import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
+import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
+import { DocBadge } from "./slide-over-doc-badge";
+
+type SlideOverInfoTabEditViewProps = {
+ draft: WorkOrderTableRow;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ vendors: VendorDropdownItem[];
+ pmTypes: string[];
+ allOrders: WorkOrderTableRow[];
+ onDraftChange: (patch: Partial) => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
+};
+
+export function SlideOverInfoTabEditView({
+ draft,
+ users,
+ sites,
+ vendors,
+ pmTypes,
+ allOrders,
+ onDraftChange,
+ onDuplicateFound,
+}: SlideOverInfoTabEditViewProps) {
+ return (
+
+
+ onDraftChange({ woNumber })}
+ onDuplicateFound={onDuplicateFound}
+ />
+
+
+
+ onDraftChange({
+ site: patch.site,
+ location: patch.location,
+ locationId: patch.locationId,
+ })
+ }
+ />
+
+
+ onDraftChange({ type })} />
+
+
+ onDraftChange({ dispatcherId: id, dispatcherName: name })}
+ />
+
+
+
+ onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
+ }
+ onSaveWeek={(weekStart) =>
+ onDraftChange({
+ scheduledOn: "",
+ targetWeek: weekStart,
+ scheduleWeekOnly: true,
+ isPastDue: false,
+ })
+ }
+ />
+
+
+ onDraftChange({ dueDate })} />
+
+
+ onDraftChange({ apptTime })}
+ />
+
+
+ onDraftChange({ pm })}
+ onSaveDetails={(details) =>
+ onDraftChange({
+ serviceNotes: details.serviceNotes,
+ extraServices: details.extraServices,
+ })
+ }
+ />
+
+
+
+ onDraftChange({
+ vendorId: patch.vendorId,
+ company: patch.company,
+ tech: patch.tech,
+ techPhone: patch.techPhone,
+ })
+ }
+ />
+
+
+ onDraftChange({ status })}
+ onOpenSlideOver={() => {}}
+ />
+
+
+
+
+
+
+ {draft.pocName || "—"}
+ {draft.pocPhone ? ` · ${draft.pocPhone}` : ""}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx
new file mode 100644
index 00000000..a01d741d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx
@@ -0,0 +1,26 @@
+import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type SlideOverInfoTabStatusFieldProps = {
+ source: WorkOrderTableRow;
+};
+
+export function SlideOverInfoTabStatusField({ source }: SlideOverInfoTabStatusFieldProps) {
+ if (source.isPastDue) {
+ return (
+
+
+ Past Due
+
+ );
+ }
+
+ if (source.status) {
+ return ;
+ }
+
+ return — ;
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx
new file mode 100644
index 00000000..389ff87e
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx
@@ -0,0 +1,45 @@
+import { useState } from "react";
+import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
+import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type SlideOverInfoTabVendorFieldProps = {
+ source: WorkOrderTableRow;
+ vendors: VendorDropdownItem[];
+};
+
+export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) {
+ const [vendorDlg, setVendorDlg] = useState(false);
+
+ if (!source.tech && !source.company) {
+ return — ;
+ }
+
+ return (
+ <>
+ setVendorDlg(true)}
+ className="text-left transition-opacity hover:opacity-80"
+ >
+
+ {source.tech || source.company}
+
+ {Boolean(source.tech && source.company) && (
+
+ {source.company}
+
+ )}
+
+ setVendorDlg(false)}
+ />
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx
new file mode 100644
index 00000000..c8d9e09b
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx
@@ -0,0 +1,64 @@
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
+import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
+
+type SlideOverInfoTabProps = {
+ row: WorkOrderTableRow;
+ draft: WorkOrderTableRow;
+ editing: boolean;
+ isCancelled: boolean;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ vendors: VendorDropdownItem[];
+ pmTypes: string[];
+ allOrders: WorkOrderTableRow[];
+ onDraftChange: (patch: Partial) => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
+};
+
+export function SlideOverInfoTab({
+ row,
+ draft,
+ editing,
+ isCancelled,
+ users,
+ sites,
+ vendors,
+ pmTypes,
+ allOrders,
+ onDraftChange,
+ onDuplicateFound,
+}: SlideOverInfoTabProps) {
+ const isEditing = editing && !isCancelled;
+ const source = isEditing ? draft : row;
+ const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
+
+ if (isEditing) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts
new file mode 100644
index 00000000..ec74d498
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts
@@ -0,0 +1,162 @@
+import { useEffect, useMemo, useState } from "react";
+import type {
+ WorkOrderTablePatch,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import {
+ buildSlideOverPatch,
+ isSlideOverDraftDirty,
+} from "@/domain/work-orders/utils/slide-over-draft";
+import type { SlideOverSaveOptions } from "./work-order-slide-over";
+import type { SlideOverTab } from "./use-work-order-slide-over";
+
+type UseSlideOverEditStateArgs = {
+ row: WorkOrderTableRow | null;
+ editMode: boolean;
+ infoSource: WorkOrderTableRow | null | undefined;
+ onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
+ onClose: () => void;
+ closeDisabled: boolean;
+ setTab: (tab: SlideOverTab) => void;
+};
+
+export function useSlideOverEditState({
+ row,
+ editMode,
+ infoSource,
+ onSave,
+ onClose,
+ closeDisabled,
+ setTab,
+}: UseSlideOverEditStateArgs) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(null);
+ /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
+ const [baseline, setBaseline] = useState(null);
+ const [showUnsaved, setShowUnsaved] = useState(false);
+ const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
+
+ // Reset draft/baseline only when the opened WO identity or editMode intent changes —
+ // not on every optimistic parent-row field update during a failed/in-flight save.
+ useEffect(() => {
+ if (!row) {
+ setDraft(null);
+ setBaseline(null);
+ setEditing(false);
+ setShowUnsaved(false);
+ setUnsavedIntent(null);
+ return;
+ }
+ setDraft(row);
+ setBaseline(row);
+ setEditing(editMode && (row.status as string) !== "Canceled");
+ setTab("info");
+ setShowUnsaved(false);
+ setUnsavedIntent(null);
+ // Intentionally identity-only: depending on `row` would absorb optimistic patches.
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
+ }, [row?.id, editMode]);
+
+ useEffect(() => {
+ if (!infoSource) return;
+ if (!editing) {
+ setDraft(infoSource);
+ setBaseline(infoSource);
+ return;
+ }
+ // While editing, freeze baseline so optimistic parent-row patches cannot clear dirty.
+ setDraft((prev) => {
+ if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev;
+ return infoSource;
+ });
+ }, [infoSource, editing, baseline]);
+
+ const activeDraft = draft ?? infoSource;
+ const dirtyBaseline = baseline ?? infoSource;
+ const isDirty = useMemo(() => {
+ if (!dirtyBaseline || !activeDraft || !editing) return false;
+ return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
+ }, [activeDraft, dirtyBaseline, editing]);
+
+ const handleDraftChange = (patch: Partial) => {
+ setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
+ };
+
+ const commit = () => {
+ if (!row || !activeDraft || !dirtyBaseline) return;
+ const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
+ if (Object.keys(patch).length === 0) {
+ setEditing(false);
+ setShowUnsaved(false);
+ return;
+ }
+ onSave(row.id, patch, {
+ onSuccess: () => {
+ setBaseline(activeDraft);
+ setEditing(false);
+ setShowUnsaved(false);
+ },
+ });
+ };
+
+ const discard = () => {
+ if (!dirtyBaseline) return;
+ setDraft(dirtyBaseline);
+ setEditing(false);
+ setShowUnsaved(false);
+ };
+
+ const requestClose = () => {
+ if (closeDisabled) return;
+ if (editing && isDirty) {
+ setUnsavedIntent("close");
+ setShowUnsaved(true);
+ return;
+ }
+ onClose();
+ };
+
+ const handleCancelEdit = () => {
+ if (isDirty) {
+ setUnsavedIntent("cancel-edit");
+ setShowUnsaved(true);
+ return;
+ }
+ if (dirtyBaseline) setDraft(dirtyBaseline);
+ setEditing(false);
+ };
+
+ const handleDiscard = () => {
+ const intent = unsavedIntent;
+ discard();
+ setUnsavedIntent(null);
+ // Close only when Discard was opened from panel close — never from cancel-edit.
+ // Skip when uploads block close (closeDisabled).
+ if (intent === "close" && !closeDisabled) {
+ onClose();
+ }
+ };
+
+ const startEditing = () => {
+ if (!infoSource) return;
+ setDraft(infoSource);
+ setBaseline(infoSource);
+ setEditing(true);
+ setTab("info");
+ };
+
+ return {
+ editing,
+ activeDraft,
+ isDirty,
+ showUnsaved,
+ setShowUnsaved,
+ handleDraftChange,
+ commit,
+ discard,
+ handleDiscard,
+ requestClose,
+ handleCancelEdit,
+ startEditing,
+ };
+}
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
new file mode 100644
index 00000000..eb6e3e33
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts
@@ -0,0 +1,101 @@
+import { useMemo, useState } from "react";
+import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
+import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
+import type {
+ useDeleteWorkOrderMedia,
+ useUpdateWorkOrderMediaCategory,
+ useUploadWorkOrderMedia,
+} from "@/domain/work-orders/use-cases/use-work-order-mutations";
+
+const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
+
+type UseSlideOverMediaStateArgs = {
+ detailForRow?: WorkOrderBoardDetail;
+ uploadMediaMutation: ReturnType;
+ deleteMediaMutation: ReturnType;
+ categorizeMediaMutation: ReturnType;
+};
+
+export function useSlideOverMediaState({
+ detailForRow,
+ uploadMediaMutation,
+ deleteMediaMutation,
+ categorizeMediaMutation,
+}: UseSlideOverMediaStateArgs) {
+ const [mediaBatchPending, setMediaBatchPending] = useState(false);
+ const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
+ useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
+
+ const completionMedia = useMemo(
+ () =>
+ uploads.filter(
+ (item) =>
+ COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
+ item.uploadStatus === "uploading" ||
+ item.uploadStatus === "failed",
+ ),
+ [uploads],
+ );
+
+ const extrasUploads = useMemo(
+ () =>
+ boardMediaItemsToMediaFiles(
+ (detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
+ ),
+ [detailForRow?.media],
+ );
+
+ const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
+ const mediaPending =
+ mediaBatchPending ||
+ hasLocalUploading ||
+ uploadMediaMutation.isPending ||
+ deleteMediaMutation.isPending ||
+ categorizeMediaMutation.isPending;
+
+ const workOrderVersion = detailForRow?.info.rowVersion?.trim() ?? "";
+
+ const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
+ startUploads(requests);
+ };
+
+ const uploadExtraFiles = async (files: File[]) => {
+ setMediaBatchPending(true);
+ try {
+ for (const file of files) {
+ await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
+ }
+ } finally {
+ setMediaBatchPending(false);
+ }
+ };
+
+ const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
+ if (!workOrderVersion) return;
+ categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
+ };
+
+ const removeMedia = (id: string) => {
+ if (isLocal(id)) {
+ removeLocal(id);
+ return;
+ }
+ if (!workOrderVersion) return;
+ deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
+ };
+
+ return {
+ completionMedia,
+ extrasUploads,
+ mediaPending,
+ uploadMediaRequests,
+ uploadExtraFiles,
+ categorizeMedia,
+ retryUpload,
+ cancelUpload,
+ removeMedia,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
new file mode 100644
index 00000000..9120bb5f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
@@ -0,0 +1,160 @@
+import { useState } from "react";
+import { toast } from "react-toastify";
+import type {
+ WorkOrderTablePatch,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import {
+ useAddWorkOrderComment,
+ useDeleteWorkOrderMedia,
+ useUpdateWorkOrderComment,
+ useUpdateWorkOrderMediaCategory,
+ useUploadCompletionDoc,
+ useUploadWorkOrderMedia,
+} from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
+import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
+import { useAuthContext } from "@/providers/auth-context";
+import { useSlideOverEditState } from "./use-slide-over-edit-state";
+import { useSlideOverMediaState } from "./use-slide-over-media-state";
+import type { SlideOverSaveOptions } from "./work-order-slide-over";
+
+export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
+
+type UseWorkOrderSlideOverArgs = {
+ row: WorkOrderTableRow | null;
+ editMode: boolean;
+ onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
+ onClose: () => void;
+ saving?: boolean;
+};
+
+export function useWorkOrderSlideOver({
+ row,
+ editMode,
+ onSave,
+ onClose,
+ saving,
+}: UseWorkOrderSlideOverArgs) {
+ const { user } = useAuthContext();
+ const workOrderId = row?.id;
+ const {
+ data: detail,
+ isLoading,
+ isFetching,
+ error,
+ refetch,
+ } = useWorkOrderBoardDetail(workOrderId, Boolean(row));
+ const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
+ const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
+ const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
+ const categorizeMediaMutation = useUpdateWorkOrderMediaCategory(workOrderId ?? "");
+ const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
+ const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
+
+ const [tab, setTab] = useState("info");
+
+ // Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
+ const detailForRow =
+ detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
+ const infoSource = detailForRow?.info ?? row;
+ const isCancelled = (infoSource?.status as string) === "Canceled";
+ const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
+
+ const {
+ completionMedia,
+ extrasUploads,
+ mediaPending,
+ uploadMediaRequests,
+ uploadExtraFiles,
+ categorizeMedia,
+ retryUpload,
+ cancelUpload,
+ removeMedia,
+ } = useSlideOverMediaState({
+ detailForRow,
+ uploadMediaMutation,
+ deleteMediaMutation,
+ categorizeMediaMutation,
+ });
+ const completionPending = uploadCompletionMutation.isPending;
+ const closeDisabled = Boolean(saving || mediaPending || completionPending);
+
+ const editState = useSlideOverEditState({
+ row,
+ editMode,
+ infoSource,
+ onSave,
+ onClose,
+ closeDisabled,
+ setTab,
+ });
+
+ const uploadCompletionPdf = (file: File) => {
+ const version = infoSource?.rowVersion;
+ if (!version || !row || !infoSource) {
+ toast.error("Missing work order version. Refresh and try again.");
+ return;
+ }
+ uploadCompletionMutation.mutate(
+ { file, workOrderVersion: version },
+ {
+ onSuccess: async () => {
+ // Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
+ const patch: WorkOrderTablePatch = { docStatus: "Yes" };
+ if (!infoSource.completedDate) {
+ patch.completedDate = todayIso();
+ }
+ try {
+ await new Promise((resolve, reject) => {
+ onSave(row.id, patch, {
+ onSuccess: () => resolve(),
+ onError: (error) => reject(error),
+ });
+ });
+ } catch {
+ toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
+ }
+ },
+ },
+ );
+ };
+
+ const showDetailSkeleton = isLoading && !detailForRow;
+ const errorMessage =
+ error instanceof Error ? error.message : "Failed to load work order details.";
+
+ return {
+ user,
+ detail,
+ isLoading,
+ isFetching,
+ error,
+ refetch,
+ commentMutation,
+ updateCommentMutation,
+ tab,
+ setTab,
+ detailForRow,
+ infoSource,
+ isCancelled,
+ isDocumentsReadOnly,
+ completionMedia,
+ extrasUploads,
+ mediaPending,
+ completionPending,
+ closeDisabled,
+ uploadMediaRequests,
+ uploadExtraFiles,
+ categorizeMedia,
+ retryUpload,
+ cancelUpload,
+ removeMedia,
+ deleteMediaMutation,
+ uploadCompletionPdf,
+ showDetailSkeleton,
+ errorMessage,
+ ...editState,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx b/src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx
new file mode 100644
index 00000000..7b72c393
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx
@@ -0,0 +1,101 @@
+import { useMemo, useState } from "react";
+import { Box, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
+import { CommentTimeline } from "@/components/common/comment-timeline";
+import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
+import { Button } from "@/components/ui/button";
+import { AuditTimeline } from "./audit-timeline";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+
+type ActivityTab = "customer" | "internal" | "audit";
+
+type WorkOrderActivitySectionProps = {
+ wo: WorkOrderDetail;
+ onCommentSubmit: (
+ text: string,
+ commentType: "customer" | "internal",
+ options?: { onSuccess?: () => void },
+ ) => void;
+ isCommentPending: boolean;
+};
+
+export function WorkOrderActivitySection({
+ wo,
+ onCommentSubmit,
+ isCommentPending,
+}: WorkOrderActivitySectionProps) {
+ const [activeTab, setActiveTab] = useState("customer");
+ const [commentText, setCommentText] = useState("");
+
+ const customerComments = useMemo(
+ () => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"),
+ [wo.comments],
+ );
+ const internalComments = useMemo(
+ () => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"),
+ [wo.comments],
+ );
+ const auditLog = wo.auditLog ?? [];
+
+ const handleCommentSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!commentText.trim()) {
+ return;
+ }
+ onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal", {
+ onSuccess: () => setCommentText(""),
+ });
+ };
+
+ return (
+
+
+ Activity
+
+ setActiveTab(value)}
+ className="mb-3"
+ >
+
+
+
+
+
+ {activeTab === "customer" && (
+
+ )}
+ {activeTab === "internal" && (
+
+ )}
+ {activeTab === "audit" && }
+
+ {activeTab === "internal" && (
+
+ setCommentText(event.target.value)}
+ placeholder="Type a comment..."
+ disabled={isCommentPending}
+ />
+
+ Send
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-assignee-menu.tsx b/src/app/(protected)/workorders/_components/detail/work-order-assignee-menu.tsx
new file mode 100644
index 00000000..fed7b761
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-assignee-menu.tsx
@@ -0,0 +1,56 @@
+import { Button, Menu, MenuItem } from "@mui/material";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import { useMenuAnchor } from "@/hooks/use-menu-anchor";
+
+type WorkOrderAssigneeMenuProps = {
+ assignedTo: string;
+ users: WorkOrderUser[];
+ disabled?: boolean;
+ onAssign: (userId: string | number, userName: string) => void;
+};
+
+export function WorkOrderAssigneeMenu({
+ assignedTo,
+ users,
+ disabled = false,
+ onAssign,
+}: WorkOrderAssigneeMenuProps) {
+ const { anchorEl, open, setAnchor, close } = useMenuAnchor();
+
+ return (
+ <>
+ setAnchor(event.currentTarget)}
+ >
+ {disabled ? "Updating…" : assignedTo || "Unassigned"}
+
+
+ {
+ close();
+ onAssign("", "");
+ }}
+ >
+ Unassigned
+
+ {users.map((user) => (
+ {
+ close();
+ onAssign(user.id, user.name);
+ }}
+ >
+ {user.name}
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx b/src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx
new file mode 100644
index 00000000..ec67beae
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx
@@ -0,0 +1,20 @@
+import { Accordion, AccordionDetails, AccordionSummary, Typography } from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+
+type WorkOrderDescriptionAccordionProps = {
+ wo: WorkOrderDetail;
+};
+
+export function WorkOrderDescriptionAccordion({ wo }: WorkOrderDescriptionAccordionProps) {
+ return (
+
+ }>
+ Description
+
+
+ {wo.description || "No description provided."}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx b/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx
new file mode 100644
index 00000000..d1ba2d09
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx
@@ -0,0 +1,49 @@
+import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import { WorkOrderActivitySection } from "./work-order-activity-section";
+import { WorkOrderDescriptionAccordion } from "./work-order-description-accordion";
+import { WorkOrderDetailsCard } from "./work-order-details-card";
+import { WorkOrderDispatchesAccordion } from "./work-order-dispatches-accordion";
+import { WorkOrderSimpleAccordions } from "./work-order-simple-accordions";
+
+type WorkOrderDetailsSectionProps = {
+ wo: WorkOrderDetail;
+ users: WorkOrderUser[];
+ onAssign: (userId: string | number, userName: string) => void;
+ isAssignPending?: boolean;
+ onDispatchClick: (dispatchId: string | number) => void;
+ onCommentSubmit: (
+ text: string,
+ commentType: "customer" | "internal",
+ options?: { onSuccess?: () => void },
+ ) => void;
+ isCommentPending: boolean;
+};
+
+export function WorkOrderDetailsSection({
+ wo,
+ users,
+ onAssign,
+ isAssignPending = false,
+ onDispatchClick,
+ onCommentSubmit,
+ isCommentPending,
+}: WorkOrderDetailsSectionProps) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx b/src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx
new file mode 100644
index 00000000..fb332ce1
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx
@@ -0,0 +1,45 @@
+import { Box, Paper, Typography } from "@mui/material";
+import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
+import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
+import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+
+type WorkOrderDetailsCardProps = {
+ wo: WorkOrderDetail;
+ users: WorkOrderUser[];
+ onAssign: (userId: string | number, userName: string) => void;
+ isAssignPending?: boolean;
+};
+
+export function WorkOrderDetailsCard({
+ wo,
+ users,
+ onAssign,
+ isAssignPending = false,
+}: WorkOrderDetailsCardProps) {
+ return (
+
+
+ Details
+
+
+
+
+
+
+
+
+ Assigned To
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx b/src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx
new file mode 100644
index 00000000..30de5b9e
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx
@@ -0,0 +1,85 @@
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
+
+type WorkOrderDispatchesAccordionProps = {
+ wo: WorkOrderDetail;
+ onDispatchClick: (dispatchId: string | number) => void;
+};
+
+export function WorkOrderDispatchesAccordion({
+ wo,
+ onDispatchClick,
+}: WorkOrderDispatchesAccordionProps) {
+ return (
+
+ }>
+ Dispatches ({wo.dispatches.length})
+
+
+ {wo.dispatches.length ? (
+
+
+
+
+ Dispatch #
+ Vendor
+ NTE
+ Status
+ Date
+ Wait
+
+
+
+ {wo.dispatches.map((dispatch) => {
+ const color = waitTimeColor(dispatch.dispatchedAt);
+ return (
+ onDispatchClick(dispatch.id)}
+ >
+ {dispatch.dispatchNumber || dispatch.poNumber}
+ {dispatch.vendorName}
+
+ {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
+
+ {dispatch.status}
+
+ {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
+
+
+ {dispatch.status === "Sent" ? (
+
+ {timeSince(dispatch.dispatchedAt)}
+
+ ) : (
+ "—"
+ )}
+
+
+ );
+ })}
+
+
+
+ ) : (
+ No dispatches yet.
+ )}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx b/src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx
new file mode 100644
index 00000000..9c8b4214
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx
@@ -0,0 +1,61 @@
+import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
+import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+import { formatDateTime } from "@/lib/time-utils";
+
+type WorkOrderSimpleAccordionsProps = {
+ wo: WorkOrderDetail;
+};
+
+export function WorkOrderSimpleAccordions({ wo }: WorkOrderSimpleAccordionsProps) {
+ return (
+ <>
+
+ }>
+ Classification
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Schedule
+
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Financial
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx
new file mode 100644
index 00000000..2efcac54
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx
@@ -0,0 +1,52 @@
+import type { SlideOverTab } from "./use-work-order-slide-over";
+
+const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
+ { id: "info", label: "Info" },
+ { id: "comments", label: "Comments" },
+ { id: "audit", label: "Audit Log" },
+ { id: "completion", label: "Completion Doc" },
+ { id: "extras", label: "Extra Docs" },
+];
+
+type WorkOrderSlideOverTabNavProps = {
+ tab: SlideOverTab;
+ saving?: boolean;
+ onTabChange: (tab: SlideOverTab) => void;
+};
+
+export function WorkOrderSlideOverTabNav({
+ tab,
+ saving,
+ onTabChange,
+}: WorkOrderSlideOverTabNavProps) {
+ return (
+
+ {SLIDE_OVER_TABS.map((item) => {
+ const active = tab === item.id;
+ return (
+ onTabChange(item.id)}
+ className="shrink-0 border-b-2 pb-2.5 text-[12px] transition-colors disabled:opacity-50"
+ style={{
+ borderBottomColor: active ? "var(--color-primary)" : "transparent",
+ color: active ? "var(--color-primary)" : "var(--color-text-muted)",
+ fontWeight: active ? 500 : 400,
+ }}
+ >
+ {item.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx
new file mode 100644
index 00000000..9f682b89
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx
@@ -0,0 +1,33 @@
+import type { ReactNode } from "react";
+import { QueryErrorPanel } from "@/components/ui/query-error-panel";
+import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton";
+
+type SlideOverTabSkeletonVariant = "comments" | "audit" | "completion" | "extras";
+
+type WorkOrderSlideOverTabPanelProps = {
+ variant: SlideOverTabSkeletonVariant;
+ showSkeleton: boolean;
+ hasError: boolean;
+ errorMessage: string;
+ onRetry: () => void;
+ showContent: boolean;
+ children: ReactNode;
+};
+
+export function WorkOrderSlideOverTabPanel({
+ variant,
+ showSkeleton,
+ hasError,
+ errorMessage,
+ onRetry,
+ showContent,
+ children,
+}: WorkOrderSlideOverTabPanelProps) {
+ return (
+ <>
+ {showSkeleton && }
+ {hasError && }
+ {showContent && children}
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx
new file mode 100644
index 00000000..7b174a87
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx
@@ -0,0 +1,156 @@
+import { Box } from "@mui/material";
+import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab";
+import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
+import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
+import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
+import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
+import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
+
+type SlideOverState = ReturnType;
+
+type WorkOrderSlideOverTabsProps = {
+ tab: SlideOverTab;
+ state: SlideOverState;
+ saving?: boolean;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ vendors: VendorDropdownItem[];
+ pmTypes: string[];
+ allOrders: WorkOrderTableRow[];
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
+ onOpenCompletionDoc: () => void;
+};
+
+export function WorkOrderSlideOverTabs({
+ tab,
+ state,
+ saving,
+ users,
+ sites,
+ vendors,
+ pmTypes,
+ allOrders,
+ onDuplicateFound,
+ onOpenCompletionDoc,
+}: WorkOrderSlideOverTabsProps) {
+ const hasError = Boolean(state.error) && !state.detailForRow;
+ const showContent = Boolean(state.detailForRow || !state.isLoading);
+ const { infoSource, activeDraft } = state;
+
+ return (
+
+ {tab === "info" && infoSource != null && activeDraft != null && (
+
+ )}
+
+ {tab === "comments" && (
+ void state.refetch()}
+ showContent={state.detailForRow != null}
+ >
+ {state.detailForRow != null && (
+ state.commentMutation.mutate({ text }, options)}
+ onUpdateComment={(commentId, text, options) =>
+ state.updateCommentMutation.mutate({ commentId, text }, options)
+ }
+ />
+ )}
+
+ )}
+
+ {tab === "audit" && (
+ void state.refetch()}
+ showContent={state.detailForRow != null}
+ >
+ {state.detailForRow != null && (
+
+ )}
+
+ )}
+
+ {tab === "completion" && state.infoSource != null && (
+ void state.refetch()}
+ showContent={showContent}
+ >
+ {
+ state.uploadMediaRequests(requests);
+ }}
+ onCategorizeMedia={state.categorizeMedia}
+ onRemoveMedia={state.removeMedia}
+ onRetryMedia={state.retryUpload}
+ onCancelMedia={state.cancelUpload}
+ onUploadCompletionPdf={state.uploadCompletionPdf}
+ onOpenCompletionDoc={onOpenCompletionDoc}
+ />
+
+ )}
+
+ {tab === "extras" && (
+ void state.refetch()}
+ showContent={showContent}
+ >
+ {
+ void state.uploadExtraFiles(files);
+ }}
+ onRemove={state.removeMedia}
+ />
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx
new file mode 100644
index 00000000..64d2e8f9
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx
@@ -0,0 +1,108 @@
+import { Box } from "@mui/material";
+import { SideSheet } from "@/components/ui/side-sheet";
+import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
+import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type {
+ WorkOrderTablePatch,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
+import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
+import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
+
+export type SlideOverSaveOptions = {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+};
+
+type WorkOrderSlideOverProps = {
+ row: WorkOrderTableRow | null;
+ editMode: boolean;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ vendors: VendorDropdownItem[];
+ pmTypes: string[];
+ allOrders: WorkOrderTableRow[];
+ onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
+ onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
+ onCancelWO: (row: WorkOrderTableRow) => void;
+ onDuplicateFound?: (existing: WorkOrderTableRow) => void;
+ onClose: () => void;
+ saving?: boolean;
+};
+
+export function WorkOrderSlideOver({
+ row,
+ editMode,
+ users,
+ sites,
+ vendors,
+ pmTypes,
+ allOrders,
+ onSave,
+ onOpenCompletionDoc,
+ onCancelWO,
+ onDuplicateFound,
+ onClose,
+ saving,
+}: WorkOrderSlideOverProps) {
+ const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
+ const { infoSource, activeDraft } = state;
+
+ return (
+
+ {row != null && infoSource != null && activeDraft != null && (
+
+
+
+
+
+ onOpenCompletionDoc(infoSource)}
+ />
+
+ onCancelWO(infoSource)}
+ onCancelEdit={state.handleCancelEdit}
+ onClose={state.requestClose}
+ onEdit={state.startEditing}
+ onSave={state.commit}
+ onDiscard={state.handleDiscard}
+ onKeepEditing={() => state.setShowUnsaved(false)}
+ />
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx b/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx
new file mode 100644
index 00000000..15945501
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx
@@ -0,0 +1,50 @@
+import { Menu, MenuItem } from "@mui/material";
+import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
+import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
+import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
+import { useMenuAnchor } from "@/hooks/use-menu-anchor";
+
+type WorkOrderStatusMenuProps = {
+ status: string;
+ disabled?: boolean;
+ onStatusChange: (status: string) => void;
+};
+
+export function WorkOrderStatusMenu({
+ status,
+ disabled = false,
+ onStatusChange,
+}: WorkOrderStatusMenuProps) {
+ const { anchorEl, open, setAnchor, close } = useMenuAnchor();
+
+ return (
+ <>
+
+
+
+
+ {WORK_ORDER_STATUS_OPTIONS.map((option) => (
+ {
+ close();
+ if (option !== status) {
+ onStatusChange(option);
+ }
+ }}
+ >
+ {option}
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts
index cdf14e52..2a2a4e2e 100644
--- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts
+++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts
@@ -12,12 +12,15 @@ export function buildInlineDraftCreateInput(
const location = sites.find((s) => String(s.id) === String(draft.locationId));
const locationName = draft.location || draft.site || location?.name || "";
+ const hasWeekTarget = Boolean(draft.targetWeek?.trim());
+ const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
+
return {
locationId: String(draft.locationId || ""),
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
woNumber: draft.woNumber,
- type: draft.type,
+ type: draft.type || "PM",
assigneeId: draft.dispatcherId,
assigneeName: assignee?.name ?? "",
scheduledOn: draft.scheduledOn,
@@ -35,7 +38,7 @@ export function buildInlineDraftCreateInput(
pocName: draft.pocName,
pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes,
- scheduleWeekOnly: draft.scheduleWeekOnly,
+ scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
isAddOnOverride: draft.isAddOnOverride,
};
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx
index 43f258c3..78a62e61 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx
@@ -1,6 +1,7 @@
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
import type { CompletionDocStatus } 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";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
@@ -9,6 +10,8 @@ function isMediaWO(type: WOType | ""): boolean {
type CompDocCellProps = {
status: CompletionDocStatus;
woType: WOType | "";
+ /** Primary service (`pm`). Required before CompDoc can open. */
+ pm?: string;
mediaCount?: number;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpen?: () => void;
@@ -18,6 +21,7 @@ type CompDocCellProps = {
export function CompDocCell({
status,
woType,
+ pm = "",
mediaCount = 0,
onOpen,
onOpenMedia,
@@ -43,6 +47,20 @@ export function CompDocCell({
return null;
}
+ if (!hasCompletionDocService(pm)) {
+ return (
+
+
+ Select service
+
+ );
+ }
+
const generated = status === "Yes";
const notRequired = status === "NN";
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
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 417ffe22..6d78143e 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
@@ -15,6 +15,8 @@ type DateCellProps = {
targetWeek?: string;
onSaveWeek?: (weekStart: string) => void;
defaultMode?: "week" | "date";
+ /** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
+ onClear?: () => void;
};
export function DateCell({
@@ -26,6 +28,7 @@ export function DateCell({
targetWeek = "",
onSaveWeek,
defaultMode,
+ onClear,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState(null);
const [cellMode, setCellMode] = useState<"week" | "date">(
@@ -132,6 +135,21 @@ export function DateCell({
}}
/>
)}
+ {Boolean(onClear) && (
+
+ {
+ onClear?.();
+ setAnchorEl(null);
+ }}
+ className="text-[11.5px] font-medium transition-colors"
+ style={{ color: "var(--color-text-muted)" }}
+ >
+ × Clear
+
+
+ )}
>
);
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx
index 939b4ce4..89a703f6 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx
@@ -17,6 +17,8 @@ type SiteCellProps = {
pocNotes?: string;
sites: LocationOption[];
q?: string;
+ /** Inline create: editable POC + Site/POC required to confirm the modal. */
+ createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@@ -31,6 +33,7 @@ export function SiteCell({
pocNotes,
sites,
q,
+ createMode = false,
onSave,
}: SiteCellProps) {
const [open, setOpen] = useState(false);
@@ -70,7 +73,15 @@ export function SiteCell({
)}
) : (
- —
+
+ {createMode ? (
+ <>
+ Site *
+ >
+ ) : (
+ "—"
+ )}
+
)}
@@ -83,6 +94,7 @@ export function SiteCell({
pocPhone={pocPhone}
pocNotes={pocNotes}
sites={sites}
+ createMode={createMode}
onSave={onSave}
/>
>
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx
index 145250c2..61ca8f78 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx
@@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
pocName: string;
pocPhone: string;
pocNotes: string;
+ editable?: boolean;
+ showErrors?: boolean;
+ onPocNameChange?: (value: string) => void;
+ onPocPhoneChange?: (value: string) => void;
+ onPocNotesChange?: (value: string) => void;
};
-export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) {
+export function SiteDialogPocFields({
+ pocName,
+ pocPhone,
+ pocNotes,
+ editable = false,
+ showErrors = false,
+ onPocNameChange,
+ onPocPhoneChange,
+ onPocNotesChange,
+}: SiteDialogPocFieldsProps) {
+ const nameMissing = showErrors && !pocName.trim();
+ const phoneMissing = showErrors && !pocPhone.trim();
+
return (
Point of Contact
- Shown from site/work order records. Board edit saves site code only.
+ {editable
+ ? "Auto-filled from records — edit if outdated"
+ : "Shown from site/work order records. Board edit saves site code only."}
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 ece94799..12c17523 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
@@ -1,30 +1,20 @@
-import { useEffect, useState, type MouseEvent } from "react";
+import type { MouseEvent } from "react";
import { MapPin } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
-import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
-import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
-import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
-import {
- extractLocationLabel,
- resolveLocationId,
-} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
+import {
+ useSiteDialogState,
+ type SitePatch,
+} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
-export type SitePatch = {
- site: string;
- location: string;
- locationId: string | number;
- pocName: string;
- pocPhone: string;
- pocNotes: string;
-};
+export type { SitePatch };
type SiteDialogProps = {
open: boolean;
@@ -35,6 +25,7 @@ type SiteDialogProps = {
pocPhone?: string;
pocNotes?: string;
sites: LocationOption[];
+ createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@@ -47,62 +38,26 @@ export function SiteDialog({
pocPhone = "",
pocNotes = "",
sites,
+ createMode = false,
onSave,
}: SiteDialogProps) {
- const [code, setCode] = useState(value);
- const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
- const [pn, setPn] = useState(pocName);
- const [pp, setPp] = useState(pocPhone);
- const [notes, setNotes] = useState(pocNotes);
- const [pocFilledFor, setPocFilledFor] = useState("");
-
- const {
- data: locationDetail,
- isLoading: locationDetailLoading,
- isError: locationDetailError,
- } = useLocationDetail(open && locId ? locId : undefined);
-
- const selected = sites.find((s) => String(s.id) === locId);
- const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
-
- useEffect(() => {
- if (!open) {
- return;
- }
- const resolved = resolveLocationId(locationId, value, sites);
- setCode(value);
- setLocId(resolved);
- setPn(pocName);
- setPp(pocPhone);
- setNotes(pocNotes);
- // Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
- setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
- }, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
-
- useEffect(() => {
- if (!open || !locId || !locationDetail || pocFilledFor === locId) {
- return;
- }
- setPn(locationDetail.contact ?? "");
- setPp(locationDetail.phone ?? "");
- setPocFilledFor(locId);
- }, [open, locId, locationDetail, pocFilledFor]);
-
- const handlePick = (id: string) => {
- const next = sites.find((s) => String(s.id) === id);
- setLocId(id);
- if (next) {
- setCode(resolveSiteCode(next));
- }
- if (id !== pocFilledFor) {
- setPocFilledFor("");
- }
- };
+ const s = useSiteDialogState({
+ open,
+ onOpenChange,
+ value,
+ locationId,
+ pocName,
+ pocPhone,
+ pocNotes,
+ sites,
+ createMode,
+ onSave,
+ });
return (
onOpenChange(false)}
+ onClose={s.close}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@@ -125,7 +80,7 @@ export function SiteDialog({
Site
+
+ Site is required
+
- {Boolean(locId) && (
+ {Boolean(s.locId) && (
)}
-
+
- onOpenChange(false)}
- >
+
Cancel
- {
- onSave({
- site: code || (selected ? resolveSiteCode(selected) : ""),
- location: selected ? extractLocationLabel(selected) : "",
- locationId: locId,
- pocName: pn,
- pocPhone: pp,
- pocNotes: notes,
- });
- onOpenChange(false);
- }}
- >
+
Save
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
new file mode 100644
index 00000000..17d44464
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
@@ -0,0 +1,142 @@
+import { useEffect, useState } from "react";
+import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
+import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
+import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
+import type { LocationOption } from "@/domain/work-orders/types/work-order";
+import {
+ extractLocationLabel,
+ resolveLocationId,
+} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
+
+export type SitePatch = {
+ site: string;
+ location: string;
+ locationId: string | number;
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+};
+
+type UseSiteDialogStateArgs = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ value: string;
+ locationId: string | number;
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ sites: LocationOption[];
+ createMode: boolean;
+ onSave: (patch: SitePatch) => void;
+};
+
+export function useSiteDialogState({
+ open,
+ onOpenChange,
+ value,
+ locationId,
+ pocName,
+ pocPhone,
+ pocNotes,
+ sites,
+ createMode,
+ onSave,
+}: UseSiteDialogStateArgs) {
+ const [code, setCode] = useState(value);
+ const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
+ const [pn, setPn] = useState(pocName);
+ const [pp, setPp] = useState(pocPhone);
+ const [notes, setNotes] = useState(pocNotes);
+ const [pocFilledFor, setPocFilledFor] = useState("");
+ const [showErrors, setShowErrors] = useState(false);
+
+ const {
+ data: locationDetail,
+ isLoading: locationDetailLoading,
+ isError: locationDetailError,
+ } = useLocationDetail(open && locId ? locId : undefined);
+
+ const selected = sites.find((s) => String(s.id) === locId);
+ const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
+ const siteMissing = !locId;
+ const pocMissing = !pn.trim() || !pp.trim();
+ const canConfirm = !siteMissing && (!createMode || !pocMissing);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const resolved = resolveLocationId(locationId, value, sites);
+ setCode(value);
+ setLocId(resolved);
+ setPn(pocName);
+ setPp(pocPhone);
+ setNotes(pocNotes);
+ setShowErrors(false);
+ setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
+ }, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
+
+ useEffect(() => {
+ if (!open || !locId || !locationDetail || pocFilledFor === locId) {
+ return;
+ }
+ // Only autofill empty fields so typed createMode POC edits are not overwritten.
+ setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
+ setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
+ setPocFilledFor(locId);
+ }, [open, locId, locationDetail, pocFilledFor]);
+
+ const handlePick = (id: string) => {
+ const next = sites.find((s) => String(s.id) === id);
+ const siteChanged = id !== locId;
+ setLocId(id);
+ if (next) {
+ setCode(resolveSiteCode(next));
+ }
+ if (siteChanged) {
+ setPn("");
+ setPp("");
+ setPocFilledFor("");
+ } else if (id !== pocFilledFor) {
+ setPocFilledFor("");
+ }
+ };
+
+ const attemptSave = () => {
+ if (createMode && !canConfirm) {
+ setShowErrors(true);
+ return;
+ }
+ onSave({
+ site: code || (selected ? resolveSiteCode(selected) : ""),
+ location: selected ? extractLocationLabel(selected) : "",
+ locationId: locId,
+ pocName: pn,
+ pocPhone: pp,
+ pocNotes: notes,
+ });
+ onOpenChange(false);
+ };
+
+ return {
+ locId,
+ pn,
+ pp,
+ notes,
+ showErrors,
+ locationDetail,
+ locationDetailLoading,
+ locationDetailError,
+ selected,
+ addressPreview,
+ siteMissing,
+ canConfirm,
+ code,
+ handlePick,
+ attemptSave,
+ setPn,
+ setPp,
+ setNotes,
+ close: () => onOpenChange(false),
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/inline-create-field.tsx b/src/app/(protected)/workorders/_components/list/table/inline-create-field.tsx
new file mode 100644
index 00000000..f7f8ce8a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/inline-create-field.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+import { Text } from "@/components/ui/text";
+
+type InlineCreateFieldProps = {
+ showError?: boolean;
+ error?: string;
+ children: ReactNode;
+};
+
+/** Bordered create-cell wrapper; error ring + helper reveal after a failed Save attempt. */
+export function InlineCreateField({ showError = false, error, children }: InlineCreateFieldProps) {
+ return (
+
+
+ {children}
+
+
+ {error}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts
index 1ec7ee46..1c4952ad 100644
--- a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts
+++ b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts
@@ -16,6 +16,7 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
location: draft.location,
dispatcherId: draft.dispatcherId,
scheduledOn: draft.scheduledOn,
+ completedDate: "",
dueDate: draft.dueDate,
pm: draft.pm,
serviceNotes: draft.serviceNotes,
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts
index 837c8622..e9c4f1ee 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts
@@ -12,8 +12,9 @@ export const WO_TABLE_COLS = [
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 },
+ { key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 },
] as const;
-export const WO_TABLE_MIN_WIDTH = 1504;
+export const WO_TABLE_MIN_WIDTH = 1634;
export const WO_TABLE_ROW_HEIGHT = 52;
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
new file mode 100644
index 00000000..ff59824a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx
@@ -0,0 +1,93 @@
+import type { RefObject } from "react";
+import type { LocationOption } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
+import { AddOnOverrideToggle } from "./add-on-override-toggle";
+import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
+import { SiteCell } from "./cells/site-cell";
+import { InlineCreateField } from "./inline-create-field";
+
+type WoTableInlineIdentityCellsProps = {
+ draft: WorkOrderTableDraft;
+ sites: LocationOption[];
+ sitePlace?: { city: string; state: string };
+ allRows: { id: string | number; woNumber: string }[];
+ siteCellRef: RefObject;
+ woCellRef: RefObject;
+ siteShowError: boolean;
+ siteError: string;
+ woShowError: boolean;
+ onPatch: (patch: Partial) => void;
+};
+
+export function WoTableInlineIdentityCells({
+ draft,
+ sites,
+ sitePlace,
+ allRows,
+ siteCellRef,
+ woCellRef,
+ siteShowError,
+ siteError,
+ woShowError,
+ onPatch,
+}: WoTableInlineIdentityCellsProps) {
+ const isUnscheduled = Boolean(!draft.scheduledOn && !draft.targetWeek);
+
+ return (
+ <>
+
+
+
+ onPatch({
+ site: p.site,
+ location: p.location,
+ locationId: p.locationId,
+ pocName: p.pocName,
+ pocPhone: p.pocPhone,
+ pocNotes: p.pocNotes,
+ })
+ }
+ />
+
+
+
+
+
+
+
onPatch({ woNumber: v })}
+ />
+ onPatch({ isAddOnOverride })}
+ />
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-actions.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-actions.tsx
index 13b22c4c..cd09fd77 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-actions.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-actions.tsx
@@ -4,6 +4,7 @@ import { Check, X } from "lucide-react";
type WoTableInlineRowActionsProps = {
canSave: boolean;
saving: boolean;
+ saveHint: string;
onSave: () => void;
onCancel: () => void;
};
@@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
export function WoTableInlineRowActions({
canSave,
saving,
+ saveHint,
onSave,
onCancel,
}: WoTableInlineRowActionsProps) {
@@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
{
if (canSave) {
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
index ffc1afeb..c691c106 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
@@ -1,3 +1,4 @@
+import type { RefObject } from "react";
import type {
LocationOption,
VendorDropdownItem,
@@ -9,12 +10,11 @@ import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
-import { SiteCell } from "./cells/site-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
-import { InlineWoNumberCell } from "./inline-wo-number-cell";
import { toInlineDraftRow } from "./to-inline-draft-row";
+import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
type WoTableInlineRowCellsProps = {
draft: WorkOrderTableDraft;
@@ -25,9 +25,22 @@ type WoTableInlineRowCellsProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
+ siteCellRef: RefObject;
+ woCellRef: RefObject;
+ siteShowError: boolean;
+ siteError: string;
+ woShowError: boolean;
onPatch: (patch: Partial) => void;
};
+function InlineMutedDashCell() {
+ return (
+
+ —
+
+ );
+}
+
function InlineScheduleOnCell({
draft,
weekDefault,
@@ -37,6 +50,7 @@ function InlineScheduleOnCell({
weekDefault: boolean;
onPatch: (patch: Partial) => void;
}) {
+ const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
return (
+ onPatch({
+ scheduledOn: "",
+ targetWeek: "",
+ scheduleWeekOnly: false,
+ isAddOnOverride: false,
+ })
+ : undefined
+ }
/>
);
@@ -75,37 +100,29 @@ export function WoTableInlineRowCells({
vendors,
pmTypes,
allRows,
+ siteCellRef,
+ woCellRef,
+ siteShowError,
+ siteError,
+ woShowError,
onPatch,
}: WoTableInlineRowCellsProps) {
const draftRow = toInlineDraftRow(draft);
return (
<>
-
-
- onPatch({
- site: p.site,
- location: p.location,
- locationId: p.locationId,
- pocName: p.pocName,
- pocPhone: p.pocPhone,
- pocNotes: p.pocNotes,
- })
- }
- />
-
-
-
+
onPatch({ type: t })} />
@@ -188,9 +205,8 @@ export function WoTableInlineRowCells({
/>
-
- —
-
+
+
>
);
}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx
index 60bc03f4..816ed9e3 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx
@@ -1,3 +1,4 @@
+import { useRef, useState, type RefObject } from "react";
import { Flag } from "lucide-react";
import type {
@@ -5,10 +6,14 @@ import type {
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
-
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
-
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
+import {
+ draftHasScheduleDate,
+ getInlineCreateMissing,
+ inlineCreateSaveHint,
+ inlineCreateSiteError,
+} from "@/domain/work-orders/utils/get-inline-create-missing";
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
@@ -16,72 +21,86 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
type WoTableInlineRowProps = {
draft: WorkOrderTableDraft;
-
draftDayKey: string;
-
users: WorkOrderUser[];
-
sites: LocationOption[];
-
sitePlace?: { city: string; state: string };
-
vendors: VendorDropdownItem[];
-
pmTypes: string[];
-
allRows: { id: string | number; woNumber: string }[];
-
saving?: boolean;
-
onChange: (draft: WorkOrderTableDraft) => void;
-
onSave: () => void;
-
onCancel: () => void;
};
+const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]);
+
+function withScheduleStatusSync(next: WorkOrderTableDraft): WorkOrderTableDraft {
+ const dated = draftHasScheduleDate(next);
+ if (dated && PRE_SCHEDULE.has(next.status)) {
+ return { ...next, status: "Scheduled" };
+ }
+ if (!dated && next.status === "Scheduled") {
+ return { ...next, status: "Pending" };
+ }
+ return next;
+}
+
export function WoTableInlineRow({
draft,
-
draftDayKey,
-
users,
-
sites,
-
sitePlace,
-
vendors,
-
pmTypes,
-
allRows,
-
saving = false,
-
onChange,
-
onSave,
-
onCancel,
}: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
+ const missing = getInlineCreateMissing(draft);
+ const canSave = !saving && missing.length === 0;
+ const [showErrors, setShowErrors] = useState(false);
+ const siteCellRef = useRef(null);
+ const woCellRef = useRef(null);
- const canSave =
- !saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
+ const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
+ const siteShowError = showErrors && siteCellMissing;
+ const woShowError = showErrors && missing.includes("woNumber");
- const patch = (p: Partial) => onChange({ ...draft, ...p });
+ const patch = (p: Partial) =>
+ onChange(withScheduleStatusSync({ ...draft, ...p }));
+
+ const focusCell = (r: RefObject) =>
+ r.current?.querySelector("input, button, [tabindex]")?.focus();
+
+ const attemptSave = () => {
+ if (saving) {
+ return;
+ }
+ if (canSave) {
+ onSave();
+ return;
+ }
+ setShowErrors(true);
+ if (siteCellMissing) {
+ focusCell(siteCellRef);
+ } else if (missing.includes("woNumber")) {
+ focusCell(woCellRef);
+ }
+ };
return (
@@ -109,7 +133,8 @@ export function WoTableInlineRow({
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
index 0ae6032b..7974f536 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
@@ -1,7 +1,9 @@
+import type { CSSProperties } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { CompDocCell } from "./cells/comp-doc-cell";
+import { DateCell } from "./cells/date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { VendorCell } from "./cells/vendor-cell";
@@ -12,7 +14,7 @@ type WoTableRowServiceCellsProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
search?: string;
- mutationCellStyle?: React.CSSProperties;
+ mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial) => void;
};
@@ -95,11 +97,23 @@ export function WoTableRowServiceCells({
handlers.onOpenCompDoc!(row) : undefined}
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
+ e.stopPropagation()}
+ >
+ onPatchRow({ completedDate: date })}
+ />
+
>
);
}
diff --git a/src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx b/src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx
new file mode 100644
index 00000000..e17a262a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx
@@ -0,0 +1,50 @@
+import {
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+} from "@mui/material";
+import { Button } from "@/components/ui/button";
+import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type WorkOrderCancelDialogProps = {
+ row: WorkOrderTableRow | null;
+ isPending: boolean;
+ onDialogClose: (() => void) | undefined;
+ onKeepWO: () => void;
+ onConfirm: () => void;
+};
+
+export function WorkOrderCancelDialog({
+ row,
+ isPending,
+ onDialogClose,
+ onKeepWO,
+ onConfirm,
+}: WorkOrderCancelDialogProps) {
+ return (
+
+ Cancel work order?
+
+
+ Cancel WO #{row?.woNumber}? The record will be kept with status Canceled.
+
+
+
+
+ Keep WO
+
+
+ Cancel WO
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx
new file mode 100644
index 00000000..a38542d6
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx
@@ -0,0 +1,90 @@
+import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
+import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
+import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
+import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
+import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
+import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+type WorkOrdersListOverlaysProps = {
+ wizardOpen: boolean;
+ onWizardOpenChange: (open: boolean) => void;
+ users: WorkOrderUser[];
+ sites: LocationOption[];
+ pmTypes: string[];
+ vendorTechs: string[];
+ advOpen: boolean;
+ onAdvOpenChange: (open: boolean) => void;
+ advInitial: WorkOrderAdvancedFilters;
+ onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
+ onClearAdvanced: () => void;
+ activeDocRow: WorkOrderTableRow | null;
+ onDocOpenChange: (open: boolean) => void;
+ completionDocs: Record;
+ onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange: (date: string) => void | Promise;
+ activeMediaRow: WorkOrderTableRow | null;
+ onMediaOpenChange: (open: boolean) => void;
+};
+
+export function WorkOrdersListOverlays({
+ wizardOpen,
+ onWizardOpenChange,
+ users,
+ sites,
+ pmTypes,
+ vendorTechs,
+ advOpen,
+ onAdvOpenChange,
+ advInitial,
+ onApplyAdvanced,
+ onClearAdvanced,
+ activeDocRow,
+ onDocOpenChange,
+ completionDocs,
+ onSaveCompDoc,
+ onDateCompletedChange,
+ activeMediaRow,
+ onMediaOpenChange,
+}: WorkOrdersListOverlaysProps) {
+ return (
+ <>
+
+
+
+
+ {activeDocRow != null && (
+
+ )}
+
+ {activeMediaRow != null && (
+
+ )}
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
index 779bdb28..ba84fa1c 100644
--- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
+++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
@@ -1,32 +1,63 @@
import { type MouseEvent } from "react";
-import { Dialog, DialogContent, DialogTitle } from "@mui/material";
+import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
+import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
+import { LoadingRegion } from "@/components/ui/loading-region";
import { Text } from "@/components/ui/text";
-import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
+import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import { MediaUploader } from "./media-uploader";
+import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import {
+ useDeleteWorkOrderMedia,
+ useUpdateWorkOrderMediaCategory,
+ useUploadWorkOrderMedia,
+} from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
+import { guardDialogClose } from "@/lib/dialog-guard";
type MediaUploadModalProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
- uploads: MediaFile[];
- onChange: (next: MediaFile[]) => void;
};
-export function MediaUploadModal({
- row,
- open,
- onOpenChange,
- uploads,
- onChange,
-}: MediaUploadModalProps) {
+function resolveWorkOrderVersion(
+ detailRowVersion: string | null | undefined,
+ rowVersion: string | null | undefined,
+): string | null {
+ const version = detailRowVersion?.trim() || rowVersion?.trim() || "";
+ return version || null;
+}
+
+export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
+ const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
+ const uploadMutation = useUploadWorkOrderMedia(row.id);
+ const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
+ const deleteMutation = useDeleteWorkOrderMedia(row.id);
+ const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
+ const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
+ useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
+
+ const pending =
+ uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
+ const handleClose = guardDialogClose(pending, () => onOpenChange(false));
+
+ const requireVersion = (): string | null => {
+ const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
+ if (!version) {
+ toast.error("Missing work order version. Refresh and try again.");
+ return null;
+ }
+ return version;
+ };
+
return (
onOpenChange(false)}
+ onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@@ -45,11 +76,42 @@ export function MediaUploadModal({
#{row.woNumber}
- Upload photos and videos for this work order.
+ Upload photos and videos for this work order, then assign Before, After, or Extra.
-
+ {isLoading && !detail ? (
+
+
+
+
+
+
+
+ ) : (
+ {
+ if (isLocal(id)) {
+ removeLocal(id);
+ return;
+ }
+ const workOrderVersion = requireVersion();
+ if (!workOrderVersion) return;
+ deleteMutation.mutate({ mediaId: id, workOrderVersion });
+ }}
+ onCategorize={(id, category) => {
+ const workOrderVersion = requireVersion();
+ if (!workOrderVersion) return;
+ categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
+ }}
+ onRetry={retryUpload}
+ onCancel={cancelUpload}
+ readOnly={isReadOnly}
+ pending={pending}
+ />
+ )}
@@ -57,6 +119,7 @@ export function MediaUploadModal({
variant="outline"
size="default"
className="text-[12px]"
+ disabled={pending}
onClick={() => onOpenChange(false)}
>
Close
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
new file mode 100644
index 00000000..e6b3aa19
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
@@ -0,0 +1,60 @@
+import type { RefObject } from "react";
+import { Upload } from "lucide-react";
+import {
+ formatMediaSizeLimit,
+ workOrderMediaAcceptAttribute,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+
+type MediaUploaderDropzoneProps = {
+ inputRef: RefObject
;
+ pending: boolean;
+ onFilesSelected: (files: FileList | null) => void;
+};
+
+export function MediaUploaderDropzone({
+ inputRef,
+ pending,
+ onFilesSelected,
+}: MediaUploaderDropzoneProps) {
+ return (
+ <>
+ inputRef.current?.click()}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={(e) => {
+ e.preventDefault();
+ onFilesSelected(e.dataTransfer.files);
+ }}
+ className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
+ style={{
+ minHeight: 120,
+ border: "1px dashed var(--color-border)",
+ borderRadius: 8,
+ }}
+ >
+
+
+ {pending ? "Uploading…" : "Drag files here or click to browse"}
+
+
+ Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
+ upload
+
+
+ {
+ onFilesSelected(e.target.files);
+ e.target.value = "";
+ }}
+ />
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-chrome.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-chrome.tsx
new file mode 100644
index 00000000..1399c90a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-chrome.tsx
@@ -0,0 +1,113 @@
+import { RotateCcw, X } from "lucide-react";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
+import { MediaUploaderGridItemPreview } from "./media-uploader-grid-item-preview";
+
+type MediaUploaderGridItemChromeProps = {
+ file: MediaFile;
+ readOnly: boolean;
+ pending: boolean;
+ onRemove: (id: string) => void;
+ onRetry?: (id: string) => void;
+ onCancel?: (id: string) => void;
+};
+
+export function MediaUploaderGridItemChrome({
+ file,
+ readOnly,
+ pending,
+ onRemove,
+ onRetry,
+ onCancel,
+}: MediaUploaderGridItemChromeProps) {
+ const uploading = file.uploadStatus === "uploading";
+ const failed = file.uploadStatus === "failed";
+ const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
+
+ return (
+
+
+ {Boolean(uploading) && (
+
+ )}
+ {Boolean(uploading && onCancel) && (
+
onCancel?.(file.id)}
+ title="Cancel upload"
+ className="absolute flex items-center justify-center"
+ style={{
+ top: 4,
+ right: 4,
+ width: 18,
+ height: 18,
+ borderRadius: 4,
+ background: "var(--color-bg-surface)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-muted)",
+ }}
+ >
+
+
+ )}
+ {Boolean(failed && onRetry) && (
+
onRetry?.(file.id)}
+ title="Retry upload"
+ className="absolute flex items-center justify-center"
+ style={{
+ top: 4,
+ left: 4,
+ width: 18,
+ height: 18,
+ borderRadius: 4,
+ background: "var(--color-bg-surface)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-muted)",
+ }}
+ >
+
+
+ )}
+ {Boolean(showDelete && !uploading) && (
+
onRemove(file.id)}
+ title="Remove"
+ disabled={pending}
+ className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
+ style={{
+ top: 4,
+ right: 4,
+ width: 18,
+ height: 18,
+ borderRadius: 4,
+ background: "var(--color-bg-surface)",
+ border: "1px solid var(--color-border)",
+ color: "var(--color-text-muted)",
+ }}
+ >
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx
new file mode 100644
index 00000000..8b42c0f4
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx
@@ -0,0 +1,67 @@
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+
+const PURPOSE_OPTIONS: UploadableMediaCategory[] = ["Before", "After", "Extra"];
+
+type MediaUploaderGridItemMetaProps = {
+ file: MediaFile;
+ readOnly: boolean;
+ pending: boolean;
+ onCategorize?: (id: string, category: UploadableMediaCategory) => void;
+};
+
+export function MediaUploaderGridItemMeta({
+ file,
+ readOnly,
+ pending,
+ onCategorize,
+}: MediaUploaderGridItemMetaProps) {
+ const failed = file.uploadStatus === "failed";
+ const needsCategory =
+ !readOnly &&
+ !file.isLegacy &&
+ file.uploadStatus !== "uploading" &&
+ file.uploadStatus !== "failed" &&
+ Boolean(onCategorize);
+
+ return (
+
+
+ {file.name}
+
+ {Boolean(failed) && (
+
+ {file.errorMessage || "Upload failed"}
+
+ )}
+ {needsCategory ? (
+
{
+ const value = event.target.value as UploadableMediaCategory;
+ if (value) onCategorize?.(file.id, value);
+ }}
+ className="w-full rounded border px-1 py-0.5 text-[10px]"
+ style={{
+ borderColor: "var(--color-border)",
+ color: "var(--color-text-primary)",
+ background: "var(--color-bg-surface)",
+ }}
+ >
+ Assign purpose…
+ {PURPOSE_OPTIONS.map((value) => (
+
+ {value}
+
+ ))}
+
+ ) : (
+
+ {file.purpose || (file.isLegacy ? "Legacy" : "")}
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx
new file mode 100644
index 00000000..3634fc1f
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx
@@ -0,0 +1,52 @@
+import {
+ File as FileIcon,
+ FileText,
+ Film,
+ Image as ImageIcon,
+ Paperclip,
+ Play,
+} from "lucide-react";
+import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
+
+const FILETYPE_ICON: Record = {
+ image: ImageIcon,
+ video: Film,
+ pdf: FileText,
+ doc: FileIcon,
+ other: Paperclip,
+};
+
+type MediaUploaderGridItemPreviewProps = {
+ file: MediaFile;
+};
+
+export function MediaUploaderGridItemPreview({ file }: MediaUploaderGridItemPreviewProps) {
+ const TypeIcon = FILETYPE_ICON[file.fileType];
+
+ if (file.fileType === "image") {
+ return (
+
+ );
+ }
+
+ if (file.fileType === "video") {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx
new file mode 100644
index 00000000..81943b68
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx
@@ -0,0 +1,46 @@
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { MediaUploaderGridItemChrome } from "./media-uploader-grid-item-chrome";
+import { MediaUploaderGridItemMeta } from "./media-uploader-grid-item-meta";
+
+type MediaUploaderGridItemProps = {
+ file: MediaFile;
+ readOnly: boolean;
+ pending: boolean;
+ onRemove: (id: string) => void;
+ onCategorize?: (id: string, category: UploadableMediaCategory) => void;
+ onRetry?: (id: string) => void;
+ onCancel?: (id: string) => void;
+};
+
+export function MediaUploaderGridItem({
+ file,
+ readOnly,
+ pending,
+ onRemove,
+ onCategorize,
+ onRetry,
+ onCancel,
+}: MediaUploaderGridItemProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx
new file mode 100644
index 00000000..052bd161
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx
@@ -0,0 +1,42 @@
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { MediaUploaderGridItem } from "./media-uploader-grid-item";
+
+type MediaUploaderGridProps = {
+ uploads: MediaFile[];
+ readOnly: boolean;
+ pending: boolean;
+ onRemove: (id: string) => void;
+ onCategorize?: (id: string, category: UploadableMediaCategory) => void;
+ onRetry?: (id: string) => void;
+ onCancel?: (id: string) => void;
+};
+
+export function MediaUploaderGrid({
+ uploads,
+ readOnly,
+ pending,
+ onRemove,
+ onCategorize,
+ onRetry,
+ onCancel,
+}: MediaUploaderGridProps) {
+ if (uploads.length === 0) return null;
+
+ return (
+
+ {uploads.map((file) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
index bd7fcc9a..6c7544f6 100644
--- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx
+++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
@@ -1,143 +1,107 @@
-import { useRef } from "react";
+import { useRef } from "react";
+import { toast } from "react-toastify";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
- File as FileIcon,
- FileText,
- Film,
- Image as ImageIcon,
- Paperclip,
- Play,
- Upload,
- X,
-} from "lucide-react";
-import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
+ canDeleteWorkOrderMedia,
+ formatMediaSizeLimit,
+ isWorkOrderFileTooLarge,
+ isWorkOrderMediaTypeAllowed,
+} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
+import { MediaUploaderDropzone } from "./media-uploader-dropzone";
+import { MediaUploaderGrid } from "./media-uploader-grid";
-const FILETYPE_ICON: Record = {
- image: ImageIcon,
- video: Film,
- pdf: FileText,
- doc: FileIcon,
- other: Paperclip,
+export type MediaUploadRequest = {
+ localId: string;
+ file: File;
+ onProgress: (percent: number) => void;
};
type MediaUploaderProps = {
uploads: MediaFile[];
- onChange: (next: MediaFile[]) => void;
+ /** Upload-then-categorize: files only ÔÇö category assigned after persist. */
+ onUpload?: (requests: MediaUploadRequest[]) => void;
+ onRemove?: (id: string) => void;
+ onCategorize?: (id: string, category: UploadableMediaCategory) => void;
+ onRetry?: (localId: string) => void;
+ onCancel?: (localId: string) => void;
+ /** Legacy local-only mode (table modal fallback). */
+ onChange?: (next: MediaFile[]) => void;
+ readOnly?: boolean;
+ pending?: boolean;
};
-export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
+export function MediaUploader({
+ uploads,
+ onUpload,
+ onRemove,
+ onCategorize,
+ onRetry,
+ onCancel,
+ onChange,
+ readOnly = false,
+ pending = false,
+}: MediaUploaderProps) {
const inputRef = useRef(null);
const addFiles = (files: FileList | null) => {
- if (!files || files.length === 0) return;
- onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
+ if (readOnly || pending || !files || files.length === 0) return;
+
+ const accepted: File[] = [];
+ for (const file of Array.from(files)) {
+ if (!isWorkOrderMediaTypeAllowed(file)) {
+ toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
+ continue;
+ }
+ if (isWorkOrderFileTooLarge(file)) {
+ toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
+ continue;
+ }
+ accepted.push(file);
+ }
+ if (accepted.length === 0) return;
+
+ if (onUpload) {
+ const requests: MediaUploadRequest[] = accepted.map((file) => ({
+ localId: `local-${crypto.randomUUID()}`,
+ file,
+ onProgress: () => undefined,
+ }));
+ onUpload(requests);
+ return;
+ }
+
+ if (onChange) {
+ onChange([...uploads, ...accepted.map(makeMediaFile)]);
+ }
};
- const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
+ const remove = (id: string) => {
+ if (readOnly || pending) return;
+ const target = uploads.find((u) => u.id === id);
+ if (target && !canDeleteWorkOrderMedia(target) && target.uploadStatus !== "failed") return;
+ if (onRemove) {
+ onRemove(id);
+ return;
+ }
+ onChange?.(uploads.filter((u) => u.id !== id));
+ };
return (
-
inputRef.current?.click()}
- onDragOver={(e) => e.preventDefault()}
- onDrop={(e) => {
- e.preventDefault();
- addFiles(e.dataTransfer.files);
- }}
- className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
- style={{
- minHeight: 120,
- border: "1px dashed var(--color-border)",
- borderRadius: 8,
- }}
- >
-
-
- Drag files here or click to browse
-
-
- Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
-
-
-
addFiles(e.target.files)}
- />
- {uploads.length > 0 && (
-
- {uploads.map((u) => {
- const TypeIcon = FILETYPE_ICON[u.fileType];
- return (
-
-
- {u.fileType === "image" ? (
-
- ) : u.fileType === "video" ? (
- <>
-
-
-
-
- >
- ) : (
-
-
-
- )}
-
remove(u.id)}
- title="Remove"
- className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
- style={{
- top: 4,
- right: 4,
- width: 18,
- height: 18,
- borderRadius: 4,
- background: "var(--color-bg-surface)",
- border: "1px solid var(--color-border)",
- color: "var(--color-text-muted)",
- }}
- >
-
-
-
-
-
- );
- })}
-
+ {!readOnly && (
+
)}
+
);
}
diff --git a/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts
new file mode 100644
index 00000000..343f9a8f
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts
@@ -0,0 +1,170 @@
+import type { QueryClient } from "@tanstack/react-query";
+import { toast } from "react-toastify";
+import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast";
+import { locationsApi } from "@/domain/locations/api/locations-api";
+import type { Location } from "@/domain/locations/types/location";
+import type {
+ CompDocData,
+ CompDocSaveStatus,
+ DocFields,
+ DocState,
+} from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
+import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+type CompDocSite = { street: string; fullAddress: string };
+
+export function buildCompDocAddressLine(site: CompDocSite): string {
+ if (site.street.trim()) return site.street.trim();
+ return site.fullAddress !== "—" ? site.fullAddress : "";
+}
+
+function locationUpdatePayload(location: Location, address: string): Location {
+ return {
+ ...location,
+ address,
+ };
+}
+
+type CreateCompDocDialogActionsParams = {
+ row: WorkOrderTableRow;
+ notes: string;
+ generatedAt: string;
+ editDateCompleted: string;
+ editAddress: string;
+ docState: DocState;
+ docFields: DocFields;
+ site: CompDocSite;
+ locId: string;
+ isMockLocation: boolean;
+ locationDetail: Location | undefined;
+ queryClient: QueryClient;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange?: (date: string) => void | Promise;
+ setDocState: (state: DocState) => void;
+ setGeneratedAt: (value: string) => void;
+};
+
+export function createCompDocDialogActions({
+ row,
+ notes,
+ generatedAt,
+ editDateCompleted,
+ editAddress,
+ docState,
+ docFields,
+ site,
+ locId,
+ isMockLocation,
+ locationDetail,
+ queryClient,
+ onSave,
+ onDateCompletedChange,
+ setDocState,
+ setGeneratedAt,
+}: CreateCompDocDialogActionsParams) {
+ async function persist(extra: Partial, status: CompDocSaveStatus): Promise {
+ await onSave({ notes, generatedAt, ...extra }, status);
+ }
+
+ async function syncCompletedDateIfChanged(): Promise {
+ if (editDateCompleted !== (row.completedDate || "")) {
+ await onDateCompletedChange?.(editDateCompleted);
+ }
+ }
+
+ async function syncAddressIfChanged(): Promise {
+ const next = editAddress.trim();
+ const current = buildCompDocAddressLine(site);
+ if (next === current) return;
+ if (isMockLocation || !locId) return;
+ if (!locationDetail) {
+ throw new Error("Location details are still loading. Try saving again.");
+ }
+
+ await locationsApi.update(locId, locationUpdatePayload(locationDetail, next));
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+ }
+
+ function refreshAuthoritativeState() {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+ if (locId && !isMockLocation) {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
+ }
+ }
+
+ async function handleMarkNotRequired() {
+ const previous = docState;
+ setDocState("not-required");
+ try {
+ await persist({}, "NN");
+ } catch (error) {
+ setDocState(previous);
+ refreshAuthoritativeState();
+ toast.error(
+ error instanceof Error ? error.message : "Failed to mark document as not required",
+ );
+ }
+ }
+
+ async function handleUndo() {
+ const previous = docState;
+ setDocState("draft");
+ try {
+ await persist({}, "No");
+ } catch (error) {
+ setDocState(previous);
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
+ }
+ }
+
+ async function handleSaveDraft() {
+ try {
+ await syncCompletedDateIfChanged();
+ await syncAddressIfChanged();
+ await persist({}, "No");
+ showCompDocDraftSavedToast();
+ } catch (error) {
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to save draft");
+ }
+ }
+
+ async function handleGenerate() {
+ if (!editDateCompleted.trim()) {
+ toast.error("Date Work Completed is required to generate the document.");
+ return;
+ }
+ const ts = fmtDocNow();
+ try {
+ // Prerequisites first; only mark Generated after completedDate/address persist.
+ await syncCompletedDateIfChanged();
+ await syncAddressIfChanged();
+ await persist({ generatedAt: ts }, "Yes");
+ setGeneratedAt(ts);
+ setDocState("generated");
+ } catch (error) {
+ // Keep Draft UI; refresh so any partially persisted date/address is authoritative.
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to generate document");
+ }
+ }
+
+ function handleDownloadPdf() {
+ printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
+ }
+
+ return {
+ handleMarkNotRequired,
+ handleUndo,
+ handleSaveDraft,
+ handleGenerate,
+ handleDownloadPdf,
+ };
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
new file mode 100644
index 00000000..ad5c470b
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
@@ -0,0 +1,157 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ buildCompDocAddressLine,
+ createCompDocDialogActions,
+} from "@/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions";
+import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
+import type {
+ CompDocData,
+ CompDocSaveStatus,
+ DocFields,
+ DocState,
+} from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
+import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
+import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
+
+type UseCompDocDialogParams = {
+ row: WorkOrderTableRow;
+ open: boolean;
+ data?: CompDocData;
+ onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
+ onDateCompletedChange?: (date: string) => void | Promise;
+};
+
+export function useCompDocDialog({
+ row,
+ open,
+ data,
+ onSave,
+ onDateCompletedChange,
+}: UseCompDocDialogParams) {
+ const queryClient = useQueryClient();
+ const locId = String(row.locationId || "");
+ const isMockLocation = locId.startsWith("mock-");
+ const { data: apiLocationDetail } = useLocationDetail(
+ isMockLocation || !locId ? undefined : locId,
+ );
+ const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
+ const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
+
+ const [notes, setNotes] = useState("");
+ const [docState, setDocState] = useState("draft");
+ const [generatedAt, setGeneratedAt] = useState("");
+ const [editDateCompleted, setEditDateCompleted] = useState("");
+ const [editAddress, setEditAddress] = useState("");
+ const seedKeyRef = useRef(null);
+ const addressSeededRef = useRef(false);
+ const addressTouchedRef = useRef(false);
+
+ // Seed once per open/WO — row/detail refetches must not wipe in-progress edits.
+ // Address waits for authoritative locationDetail (when a real location id exists)
+ // and still accepts that first response without overwriting later user edits.
+ useEffect(() => {
+ if (!open) {
+ seedKeyRef.current = null;
+ addressSeededRef.current = false;
+ addressTouchedRef.current = false;
+ return;
+ }
+
+ const key = String(row.id);
+ if (seedKeyRef.current !== key) {
+ seedKeyRef.current = key;
+ addressSeededRef.current = false;
+ addressTouchedRef.current = false;
+ setNotes(data?.notes ?? "");
+ setDocState(docStateOf(row.docStatus));
+ setGeneratedAt(data?.generatedAt ?? "");
+ setEditDateCompleted(row.completedDate || "");
+ }
+
+ if (addressTouchedRef.current || addressSeededRef.current) {
+ return;
+ }
+
+ const awaitingLocation = Boolean(locId) && !isMockLocation && !locationDetail;
+ if (awaitingLocation) {
+ return;
+ }
+
+ setEditAddress(buildCompDocAddressLine(resolveCompDocSite(row, locationDetail)));
+ addressSeededRef.current = true;
+ }, [open, row, data?.notes, data?.generatedAt, locationDetail, locId, isMockLocation]);
+
+ const handleAddressChange = (value: string) => {
+ addressTouchedRef.current = true;
+ setEditAddress(value);
+ };
+
+ const amazonRep = row.pocName || "";
+ const seaRep = row.tech || "";
+ const cfg = DOC_STATE_CFG[docState];
+ const isNotRequired = docState === "not-required";
+ const isGenerated = docState === "generated";
+
+ const effectiveStreet = editAddress.trim() || site.street;
+ const docFields: DocFields = {
+ street: effectiveStreet,
+ city: site.city,
+ state: site.state,
+ zip: site.zip,
+ dateCompleted: editDateCompleted,
+ amazonRep,
+ seaRep,
+ };
+
+ const previewSite = useMemo(
+ () => ({
+ ...site,
+ street: effectiveStreet,
+ fullAddress:
+ [effectiveStreet, site.label, site.zip].filter(Boolean).join(", ") ||
+ effectiveStreet ||
+ "—",
+ }),
+ [site, effectiveStreet],
+ );
+
+ const actions = createCompDocDialogActions({
+ row,
+ notes,
+ generatedAt,
+ editDateCompleted,
+ editAddress,
+ docState,
+ docFields,
+ site,
+ locId,
+ isMockLocation,
+ locationDetail,
+ queryClient,
+ onSave,
+ onDateCompletedChange,
+ setDocState,
+ setGeneratedAt,
+ });
+
+ return {
+ site: previewSite,
+ notes,
+ setNotes,
+ docState,
+ generatedAt,
+ editAddress,
+ setEditAddress: handleAddressChange,
+ editDateCompleted,
+ setEditDateCompleted,
+ amazonRep,
+ seaRep,
+ cfg,
+ isNotRequired,
+ isGenerated,
+ ...actions,
+ };
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts
new file mode 100644
index 00000000..ae2ff50c
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts
@@ -0,0 +1,199 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
+import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
+import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
+
+type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal };
+
+type UploadMutate = (input: {
+ file: File;
+ signal?: AbortSignal;
+ onProgress?: (percent: number) => void;
+}) => Promise;
+
+type SessionUploadRequest = {
+ localId: string;
+ file: File;
+ signal: AbortSignal;
+};
+
+function toLocalUpload(request: SessionUploadRequest): LocalUpload {
+ const classified = classifyUpload(request.file);
+ return {
+ id: request.localId,
+ name: request.file.name,
+ fileType: classified.fileType,
+ url: URL.createObjectURL(request.file),
+ category: classified.category,
+ purpose: null,
+ progress: 0,
+ uploadStatus: "uploading" as const,
+ sourceFile: request.file,
+ signal: request.signal,
+ };
+}
+
+function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
+ return item.id === request.localId && item.signal === request.signal;
+}
+
+function revokeBlobUrl(url: string) {
+ if (url.startsWith("blob:")) {
+ URL.revokeObjectURL(url);
+ }
+}
+
+export function useMediaUploadSession(
+ detail: WorkOrderBoardDetail | undefined,
+ uploadMutateAsync: UploadMutate,
+) {
+ const [localUploads, setLocalUploads] = useState([]);
+ const controllersRef = useRef(new Map());
+ const workOrderId = detail?.info.id;
+ const previousWorkOrderIdRef = useRef(workOrderId);
+
+ // Drop local tiles and abort in-flight uploads when the bound WO changes.
+ // Skip the first bind (undefined  id) so an early upload is not cleared.
+ useEffect(() => {
+ if (previousWorkOrderIdRef.current === workOrderId) return;
+ const hadPrevious = previousWorkOrderIdRef.current != null;
+ previousWorkOrderIdRef.current = workOrderId;
+ if (!hadPrevious) return;
+
+ for (const controller of controllersRef.current.values()) {
+ controller.abort();
+ }
+ controllersRef.current.clear();
+ setLocalUploads((prev) => {
+ for (const item of prev) {
+ revokeBlobUrl(item.url);
+ }
+ return [];
+ });
+ }, [workOrderId]);
+
+ const serverUploads = useMemo(
+ () => boardMediaItemsToMediaFiles(detail?.media ?? []),
+ [detail?.media],
+ );
+
+ const uploads = useMemo(() => {
+ const serverIds = new Set(serverUploads.map((item) => item.id));
+ const pendingLocals = localUploads.filter(
+ (item) => item.uploadStatus === "uploading" || item.uploadStatus === "failed",
+ );
+ return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
+ }, [serverUploads, localUploads]);
+
+ function clearController(localId: string) {
+ controllersRef.current.delete(localId);
+ }
+
+ function replaceController(localId: string): AbortController {
+ controllersRef.current.get(localId)?.abort();
+ const controller = new AbortController();
+ controllersRef.current.set(localId, controller);
+ return controller;
+ }
+
+ function startUploads(requests: MediaUploadRequest[]) {
+ const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
+ localId: request.localId,
+ file: request.file,
+ signal: replaceController(request.localId).signal,
+ }));
+
+ setLocalUploads((prev) => {
+ const next = [...prev];
+ for (const request of sessionRequests) {
+ const entry = toLocalUpload(request);
+ const index = next.findIndex((item) => item.id === request.localId);
+ if (index >= 0) {
+ const previous = next[index];
+ if (previous) {
+ revokeBlobUrl(previous.url);
+ }
+ next[index] = entry;
+ } else {
+ next.push(entry);
+ }
+ }
+ return next;
+ });
+
+ for (const request of sessionRequests) {
+ void uploadMutateAsync({
+ file: request.file,
+ signal: request.signal,
+ onProgress: (percent) => {
+ setLocalUploads((prev) =>
+ prev.map((item) =>
+ isSameAttempt(item, request) ? { ...item, progress: percent } : item,
+ ),
+ );
+ },
+ })
+ .then(() => {
+ clearController(request.localId);
+ setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
+ })
+ .catch((error: Error) => {
+ if (error.name === "AbortError") {
+ clearController(request.localId);
+ setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
+ return;
+ }
+ // Keep controller entry only while retryable; replaceController on retry.
+ setLocalUploads((prev) =>
+ prev.map((item) =>
+ isSameAttempt(item, request)
+ ? {
+ ...item,
+ uploadStatus: "failed",
+ errorMessage: error.message || "Upload failed",
+ progress: undefined,
+ }
+ : item,
+ ),
+ );
+ });
+ }
+ }
+
+ function retryUpload(localId: string) {
+ const target = localUploads.find((item) => item.id === localId);
+ if (!target?.sourceFile) return;
+ startUploads([
+ {
+ localId,
+ file: target.sourceFile,
+ onProgress: () => undefined,
+ },
+ ]);
+ }
+
+ function cancelUpload(localId: string) {
+ controllersRef.current.get(localId)?.abort();
+ clearController(localId);
+ setLocalUploads((prev) => {
+ const target = prev.find((item) => item.id === localId);
+ if (target) {
+ revokeBlobUrl(target.url);
+ }
+ return prev.filter((item) => item.id !== localId);
+ });
+ }
+
+ function removeLocal(id: string) {
+ clearController(id);
+ setLocalUploads((prev) => prev.filter((item) => item.id !== id));
+ }
+
+ function isLocal(id: string) {
+ return localUploads.some((item) => item.id === id);
+ }
+
+ return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal };
+}
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
new file mode 100644
index 00000000..b2963c11
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
@@ -0,0 +1,131 @@
+import { useMemo, useState } from "react";
+import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
+import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import {
+ useWorkOrderLocations,
+ useWorkOrderVendors,
+} 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 { guardDialogClose } from "@/lib/dialog-guard";
+import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
+import { useWorkOrdersTableData } from "./use-work-orders-table-data";
+
+export function useWorkOrdersListPage() {
+ const [wizardOpen, setWizardOpen] = useState(false);
+ const [slideOverRow, setSlideOverRow] = useState(null);
+ const [slideOverEdit, setSlideOverEdit] = useState(false);
+ const [confirmCancel, setConfirmCancel] = useState(null);
+ const [docRow, setDocRow] = useState(null);
+ const [mediaRow, setMediaRow] = useState(null);
+ const [completionDocs, setCompletionDocs] = useState>({});
+ const filters = useWorkOrdersListFilters();
+ const tableData = useWorkOrdersTableData(filters);
+ const {
+ patchField,
+ patchFieldAsync,
+ isPending: isPatchPending,
+ } = useWorkOrderTableMutations({
+ onPatch: tableData.patchRow,
+ clearPatch: tableData.clearRowPatch,
+ getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
+ });
+
+ const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
+ const { data: locations = [] } = useWorkOrderLocations();
+ const { data: vendors = [] } = useWorkOrderVendors();
+ const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
+
+ const pmTypes = useMemo(
+ () => problemOptions.map((option) => option.value).filter(Boolean),
+ [problemOptions],
+ );
+
+ const vendorTechs = useMemo(
+ () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
+ [vendors],
+ );
+
+ const activeSlideOverRow = useMemo(() => {
+ if (!slideOverRow) return null;
+ return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow;
+ }, [slideOverRow, tableData.rows]);
+
+ const activeDocRow = useMemo(() => {
+ if (!docRow) return null;
+ return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
+ }, [docRow, tableData.rows]);
+
+ const activeMediaRow = useMemo(() => {
+ if (!mediaRow) return null;
+ return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
+ }, [mediaRow, tableData.rows]);
+
+ const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
+ setSlideOverRow(row);
+ setSlideOverEdit(edit);
+ };
+
+ const handleCloseSlideOver = () => {
+ setSlideOverRow(null);
+ setSlideOverEdit(false);
+ tableData.setSelectedId(null);
+ };
+
+ const handleConfirmCancel = () => {
+ if (!confirmCancel) return;
+ patchField(confirmCancel.id, { status: "Canceled" });
+ setConfirmCancel(null);
+ handleCloseSlideOver();
+ };
+
+ const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
+
+ const saveCompletionDoc = async (
+ row: WorkOrderTableRow,
+ data: CompDocData,
+ status: CompDocSaveStatus,
+ ) => {
+ // Persist PM comments + generatedAt in session state (no dedicated BE field yet)
+ // before patching board docStatus — Save draft / Generate / NN all share this path.
+ const nextData: CompDocData = {
+ notes: data.notes,
+ generatedAt: data.generatedAt,
+ };
+ setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData }));
+ await patchFieldAsync(row.id, { docStatus: status });
+ };
+
+ return {
+ wizardOpen,
+ setWizardOpen,
+ slideOverEdit,
+ confirmCancel,
+ setConfirmCancel,
+ docRow,
+ setDocRow,
+ mediaRow,
+ setMediaRow,
+ completionDocs,
+ filters,
+ tableData,
+ patchField,
+ patchFieldAsync,
+ isPatchPending,
+ users,
+ usersLoading,
+ locations,
+ vendors,
+ pmTypes,
+ vendorTechs,
+ activeSlideOverRow,
+ activeDocRow,
+ activeMediaRow,
+ handleOpenSlideOver,
+ handleCloseSlideOver,
+ handleConfirmCancel,
+ cancelDialogClose,
+ saveCompletionDoc,
+ };
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts
index ad0797e9..82cd07c6 100644
--- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts
@@ -49,12 +49,16 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
const startDayDraft = useCallback(
(key: string, scheduledIso?: string) => {
setDraftDayKey(key);
+ // Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
+ if (key === UNSCHEDULED_DAY_KEY) {
+ setDraft({ ...EMPTY_TABLE_DRAFT });
+ return;
+ }
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
setDraft({
...EMPTY_TABLE_DRAFT,
scheduledOn,
- scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
- targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
+ status: scheduledOn ? "Scheduled" : "Incomplete",
});
},
[weekMonday],
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index 8e0173e6..672919ee 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -1,64 +1,23 @@
-import { useMemo, useState } from "react";
import { Box } from "@mui/material";
-import { useNavigate } from "react-router";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
+import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
+import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
+import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
-import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
-import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
-import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
-import {
- useWorkOrderLocations,
- useWorkOrderVendors,
-} from "@/domain/work-orders/use-cases/use-work-order-detail";
-import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
-import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
-import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
+import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
- const navigate = useNavigate();
const { user } = useAuthContext();
- const [wizardOpen, setWizardOpen] = useState(false);
- const [mediaRow, setMediaRow] = useState(null);
- const [mediaFiles, setMediaFiles] = useState>({});
- const filters = useWorkOrdersListFilters();
- const tableData = useWorkOrdersTableData(filters);
- const { patchField } = useWorkOrderTableMutations({
- onPatch: tableData.patchRow,
- clearPatch: tableData.clearRowPatch,
- getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
- });
-
- const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
- const { data: locations = [] } = useWorkOrderLocations();
- const { data: vendors = [] } = useWorkOrderVendors();
- const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
-
- const pmTypes = useMemo(
- () => problemOptions.map((option) => option.value).filter(Boolean),
- [problemOptions],
- );
-
- const vendorTechs = useMemo(
- () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
- [vendors],
- );
-
- const totalCount = tableData.totalCount;
- const scopeTotalCount = tableData.scopeTotalCount;
-
- const activeMediaRow = useMemo(() => {
- if (!mediaRow) return null;
- return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
- }, [mediaRow, tableData.rows]);
+ const page = useWorkOrdersListPage();
+ const { filters, tableData, activeDocRow } = page;
return (
@@ -71,7 +30,7 @@ export default function WorkOrdersListPage() {
search={filters.search}
onSearchChange={filters.handleSearchChange}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
- onNewWorkOrder={() => setWizardOpen(true)}
+ onNewWorkOrder={() => page.setWizardOpen(true)}
activeFilterCount={filters.activeFilterCount}
isFetching={tableData.isFetching && !tableData.isLoading}
/>
@@ -85,19 +44,19 @@ export default function WorkOrdersListPage() {
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
onTypeChange={filters.setTypeFilter}
- users={users}
- usersLoading={usersLoading}
+ users={page.users}
+ usersLoading={page.usersLoading}
currentUserId={user?.id}
- filteredCount={totalCount}
- scopeTotalCount={scopeTotalCount}
+ filteredCount={tableData.totalCount}
+ scopeTotalCount={tableData.scopeTotalCount}
/>
{filters.advApplied != null && (
@@ -118,47 +77,79 @@ export default function WorkOrdersListPage() {
{
- tableData.setSelectedId(row.id);
- navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
- }}
- onOpenMedia={setMediaRow}
+ onOpenSlideOver={page.handleOpenSlideOver}
+ onOpenCompDoc={page.setDocRow}
+ onOpenMedia={page.setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
- onPatch={patchField}
+ onPatch={page.patchField}
/>
+
+
-
+ page.patchField(id, patch, options)}
+ onOpenCompletionDoc={page.setDocRow}
+ onCancelWO={page.setConfirmCancel}
+ onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
+ saving={page.isPatchPending}
+ onClose={page.handleCloseSlideOver}
+ />
- {activeMediaRow != null && (
+ page.setConfirmCancel(null)}
+ onConfirm={page.handleConfirmCancel}
+ />
+
+ {page.activeMediaRow != null && (
{
- if (!open) setMediaRow(null);
+ if (!open) page.setMediaRow(null);
}}
- uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
- onChange={(files) => {
- setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
- patchField(activeMediaRow.id, { mediaCount: files.length });
+ />
+ )}
+
+ {activeDocRow != null && (
+ {
+ if (!open) page.setDocRow(null);
+ }}
+ data={page.completionDocs[String(activeDocRow.id)]}
+ onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
+ onDateCompletedChange={async (date) => {
+ await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts
index 0560785e..97ca0d2b 100644
--- a/src/domain/work-orders/api/work-order-board-documents-api.ts
+++ b/src/domain/work-orders/api/work-order-board-documents-api.ts
@@ -115,12 +115,46 @@ export const workOrderBoardDocumentsApi = {
uploadMedia: async (
workOrderId: string | number,
file: File,
- category: UploadableMediaCategory,
+ category?: UploadableMediaCategory,
+ options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
): Promise => {
const formData = buildMediaFormData(file, category);
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
body: formData,
throwHttpErrors: false,
+ signal: options?.signal,
+ onUploadProgress: options?.onProgress
+ ? (progress) => {
+ options.onProgress?.(Math.round(progress.percent * 100));
+ }
+ : undefined,
+ });
+ const data = await readResponseJson(response);
+ if (response.ok) {
+ return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
+ }
+ throwDocumentHttpError(response, data);
+ },
+
+ updateMediaCategory: async (
+ workOrderId: string | number,
+ mediaId: string | number,
+ category: UploadableMediaCategory,
+ workOrderVersion: string,
+ ): Promise => {
+ if (!workOrderVersion) {
+ throw new BoardPatchValidationError(
+ "WorkOrderVersionRequired",
+ "workOrderVersion is required to update media category.",
+ );
+ }
+
+ const formData = new FormData();
+ formData.append("category", category);
+ formData.append("workOrderVersion", workOrderVersion);
+ const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
+ body: formData,
+ throwHttpErrors: false,
});
const data = await readResponseJson(response);
if (response.ok) {
@@ -129,7 +163,18 @@ export const workOrderBoardDocumentsApi = {
throwDocumentHttpError(response, data);
},
- deleteMedia: async (workOrderId: string | number, mediaId: string | number): Promise => {
+ deleteMedia: async (
+ workOrderId: string | number,
+ mediaId: string | number,
+ workOrderVersion: string,
+ ): Promise => {
+ if (!workOrderVersion) {
+ throw new BoardPatchValidationError(
+ "WorkOrderVersionRequired",
+ "workOrderVersion is required to delete media.",
+ );
+ }
+
const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId);
if (!Number.isFinite(numericId) || numericId <= 0) {
throw new BoardPatchValidationError(
@@ -139,6 +184,7 @@ export const workOrderBoardDocumentsApi = {
}
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
+ searchParams: { workOrderVersion },
throwHttpErrors: false,
});
diff --git a/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts b/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts
index 3a6c656d..c47021a3 100644
--- a/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts
+++ b/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts
@@ -14,11 +14,11 @@ export function assignWizardScheduleFields(
const isRange = draft.schedMode === "range";
if (!isRange && draft.scheduleWeekOnly) {
- payload.scheduleWeekOnly = true;
-
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
+ // Only emit week-mode flags when a concrete week exists (SH-109: undated create).
if (week) {
+ payload.scheduleWeekOnly = true;
payload.targetWeek = week;
}
} else if (!isRange && draft.scheduledOn) {
diff --git a/src/domain/work-orders/mappers/doc-status-mapper.ts b/src/domain/work-orders/mappers/doc-status-mapper.ts
new file mode 100644
index 00000000..90c9c016
--- /dev/null
+++ b/src/domain/work-orders/mappers/doc-status-mapper.ts
@@ -0,0 +1,28 @@
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+
+/** UI uses `NN`; board/API enum uses `NotNeeded` (also numeric 3). */
+export function mapDocStatusToApi(value: string): string {
+ if (value === "NN") {
+ return "NotNeeded";
+ }
+ return value;
+}
+
+export function readDocStatusFromRecord(record: Record): CompletionDocStatus {
+ const rawValue =
+ record.docStatus ?? record.DocStatus ?? record.completionDoc ?? record.CompletionDoc;
+ if (typeof rawValue === "number") {
+ if (rawValue === 1) return "Yes";
+ if (rawValue === 2) return "No";
+ if (rawValue === 3) return "NN";
+ }
+ if (typeof rawValue === "string") {
+ if (rawValue === "Yes" || rawValue === "No" || rawValue === "NN") {
+ return rawValue;
+ }
+ if (rawValue === "NotNeeded") {
+ return "NN";
+ }
+ }
+ return "";
+}
diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
index 332c677d..6a67dd5d 100644
--- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
+++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
@@ -1,17 +1,13 @@
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
+import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
export type BoardFieldOp = {
field: WorkOrderBoardEditableField;
value: string;
};
-export function mapDocStatusToApi(value: string): string {
- if (value === "NN") {
- return "NotNeeded";
- }
- return value;
-}
+export { mapDocStatusToApi };
type ScalarBoardOpRule = {
key: keyof WorkOrderTablePatch;
@@ -30,6 +26,7 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "vendorId", field: "vendorId", toValue: (value) => String(value) },
{ key: "apptTime", field: "apptTime", toValue: (value) => value as string },
{ key: "docStatus", field: "docStatus", toValue: (value) => mapDocStatusToApi(value as string) },
+ { key: "completedDate", field: "completedDate", toValue: (value) => value as string },
{ key: "pm", field: "pm", toValue: (value) => value as string },
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
diff --git a/src/domain/work-orders/mappers/wizard-draft-mapper.ts b/src/domain/work-orders/mappers/wizard-draft-mapper.ts
index 6d6775a6..bd4f6ef6 100644
--- a/src/domain/work-orders/mappers/wizard-draft-mapper.ts
+++ b/src/domain/work-orders/mappers/wizard-draft-mapper.ts
@@ -108,6 +108,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
dueDate: draft.dueDate,
scheduledDate: draft.scheduleWeekOnly ? "" : draft.scheduledOn,
completedDate: "",
+ docStatus: "",
assignedTo: draft.assigneeName,
description: buildDescription(draft),
po: "",
diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
index 3b513279..4612756d 100644
--- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
@@ -147,9 +147,11 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder
export type UploadableMediaCategory = "Before" | "After" | "Extra";
-export function buildMediaFormData(file: File, category: UploadableMediaCategory): FormData {
+export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData {
const form = new FormData();
- form.append("category", category);
+ if (category) {
+ form.append("category", category);
+ }
form.append("file", file);
return form;
}
@@ -257,7 +259,9 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M
fileType: classifyFileName(item.fileName, item.url),
url: item.url,
category: boardCategoryToMediaCategory(item.category),
+ purpose: item.category,
isLegacy: item.isLegacy || Number(item.id) <= 0,
+ uploadStatus: "done" as const,
}));
}
diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts
index f0091b55..259c5344 100644
--- a/src/domain/work-orders/mappers/work-order-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-mapper.ts
@@ -1,3 +1,4 @@
+import { readDocStatusFromRecord } from "@/domain/work-orders/mappers/doc-status-mapper";
import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type {
EmployeeOption,
@@ -14,6 +15,8 @@ import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-comm
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
import { toDateInputValue } from "@/lib/time-utils";
+export { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
+
function asRecord(raw: unknown): Record {
return typeof raw === "object" && raw !== null ? (raw as Record) : {};
}
@@ -214,6 +217,7 @@ export { mapWorkOrderTableRow, mapWorkOrderTableRows } from "./work-order-table-
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
const item = asRecord(raw);
+ const authorId = readString(item, "authorId", "AuthorId") || null;
const mentionsRaw = item.mentions ?? item.Mentions;
const mentions = Array.isArray(mentionsRaw)
? mentionsRaw
@@ -226,7 +230,8 @@ export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
createdDate: readString(item, "createdDate", "CreatedDate"),
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
commentType: readString(item, "commentType", "CommentType"),
- userName: readString(item, "userName", "UserName"),
+ userName: readString(item, "userName", "UserName", "authorName", "AuthorName"),
+ authorId,
commenter: readString(item, "commenter", "Commenter") || null,
recordType: readString(item, "recordType", "RecordType") || null,
mentions,
@@ -309,6 +314,8 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
+ // DocStatus is a board field — persist via PATCH …/board, not EditWorkorder.
+ docStatus: readDocStatusFromRecord(wo),
source: readString(wo, "source", "Source"),
...(contactIds !== undefined ? { contactIds } : {}),
...(categoryIds !== undefined ? { categoryIds } : {}),
@@ -342,6 +349,7 @@ export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrd
dueDate: detail.dueDate,
scheduledDate: detail.scheduledDate,
completedDate: detail.completedDate,
+ docStatus: detail.docStatus,
assignedTo: detail.assignedTo,
description: detail.description,
po: detail.po,
diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
index 63d50eb7..1241e844 100644
--- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
@@ -277,6 +277,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
dispatcherId,
dispatcherName,
scheduledOn,
+ completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
dayGroup,
dayLabel,
diff --git a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
index e4c2399c..8504fa09 100644
--- a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
+++ b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
@@ -153,6 +153,7 @@ export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrder
vendorNTE: values.vendorNTE,
scheduledDate: values.scheduledDate,
completedDate: values.completedDate,
+ docStatus: values.docStatus ?? "",
source: values.source,
comments: [],
auditLog: [],
diff --git a/src/domain/work-orders/types/work-order-board-patch.ts b/src/domain/work-orders/types/work-order-board-patch.ts
new file mode 100644
index 00000000..29f0edd2
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-board-patch.ts
@@ -0,0 +1,8 @@
+/** Payload for PATCH /workorders/{id}/board (one field per request). */
+export interface WorkOrderBoardPatchPayload {
+ field: string;
+ value?: string | null;
+ workOrderVersion: string;
+ dispatchVersion?: string | null;
+ primaryDispatchId?: number | null;
+}
diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts
index bac85127..bcc34447 100644
--- a/src/domain/work-orders/types/work-order-board.ts
+++ b/src/domain/work-orders/types/work-order-board.ts
@@ -121,6 +121,7 @@ export type WorkOrderBoardEditableField =
| "apptDate"
| "apptTime"
| "docStatus"
+ | "completedDate"
| "pm"
| "serviceNotes"
| "extraServices"
@@ -153,6 +154,8 @@ export interface WorkOrderBoardRowApi {
flagColor?: string | null;
dueDate?: string | null;
scheduledDate?: string | null;
+ /** ISO date when work was completed; optional on list rows until BE confirms. */
+ completedDate?: string | null;
targetWeek?: string | null;
scheduleWeekOnly?: boolean | null;
dayGroup?: string | null;
diff --git a/src/domain/work-orders/types/work-order-detail.ts b/src/domain/work-orders/types/work-order-detail.ts
index d3d2fa87..0eb2b780 100644
--- a/src/domain/work-orders/types/work-order-detail.ts
+++ b/src/domain/work-orders/types/work-order-detail.ts
@@ -1,5 +1,6 @@
import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
import type { WorkOrderDispatchSummary } from "./dispatch";
+import type { CompletionDocStatus } from "./work-order-table-row";
export interface WorkOrderComment {
id: string | number;
@@ -51,6 +52,8 @@ export interface WorkOrderDetail {
vendorNTE: string;
scheduledDate: string;
completedDate: string;
+ /** Completion-document status (API DocStatus); persisted via board PATCH. */
+ docStatus: CompletionDocStatus;
source: string;
/** Present when GetById returns contacts; used to re-send on edit so backend does not wipe. */
contactIds?: number[];
@@ -72,6 +75,8 @@ export interface WorkOrderFormValues {
dueDate: string;
scheduledDate: string;
completedDate: string;
+ /** Optional: absent from the wizard's zod-validated shape, present once loaded from the server. */
+ docStatus?: CompletionDocStatus;
assignedTo: string;
description: string;
po: string;
@@ -99,6 +104,7 @@ export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
dueDate: "",
scheduledDate: "",
completedDate: "",
+ docStatus: "",
assignedTo: "",
description: "",
po: "",
diff --git a/src/domain/work-orders/types/work-order-media.ts b/src/domain/work-orders/types/work-order-media.ts
index 68fd8faf..a5909850 100644
--- a/src/domain/work-orders/types/work-order-media.ts
+++ b/src/domain/work-orders/types/work-order-media.ts
@@ -9,8 +9,14 @@ export interface MediaFile {
fileType: FileCategory;
url: string;
category: MediaCategory;
+ /** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
+ purpose?: "Before" | "After" | "Extra" | "Completion" | null;
/** True when media is a legacy Before/After/Completion column (not deletable). */
isLegacy?: boolean;
+ /** 0–100 while uploading; undefined when idle/complete. */
+ progress?: number;
+ uploadStatus?: "uploading" | "failed" | "done";
+ errorMessage?: string;
}
export const MEDIA_CATEGORIES: MediaCategory[] = [
diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts
index 510b5d6a..5bb5970f 100644
--- a/src/domain/work-orders/types/work-order-table-row.ts
+++ b/src/domain/work-orders/types/work-order-table-row.ts
@@ -12,6 +12,8 @@ export interface WorkOrderTableRow {
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
+ /** Work completed date (API CompletedDate); distinct from scheduledOn. */
+ completedDate: string;
targetWeek: string;
dayGroup: string;
dayLabel: string;
diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
index e795c290..156cdbde 100644
--- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts
+++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
@@ -51,7 +51,20 @@ export interface UpdateWorkOrderCommentInput {
export interface UploadWorkOrderMediaInput {
file: File;
+ category?: UploadableMediaCategory;
+ signal?: AbortSignal;
+ onProgress?: (percent: number) => void;
+}
+
+export interface UpdateWorkOrderMediaCategoryInput {
+ mediaId: string | number;
category: UploadableMediaCategory;
+ workOrderVersion: string;
+}
+
+export interface DeleteWorkOrderMediaInput {
+ mediaId: string | number;
+ workOrderVersion: string;
}
export interface UploadCompletionDocInput {
@@ -192,12 +205,12 @@ export function useUploadWorkOrderMedia(
const queryClient = useQueryClient();
return useMutation({
- mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
- workOrdersApi.uploadMedia(workOrderId, file, category),
+ mutationFn: ({ file, category, signal, onProgress }: UploadWorkOrderMediaInput) =>
+ workOrdersApi.uploadMedia(workOrderId, file, category, { signal, onProgress }),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
- toast.success("File uploaded");
+ toast.success("File uploaded — assign a category");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to upload file");
@@ -205,13 +218,33 @@ export function useUploadWorkOrderMedia(
});
}
-export function useDeleteWorkOrderMedia(
+export function useUpdateWorkOrderMediaCategory(
workOrderId: string | number,
-): UseMutationResult {
+): UseMutationResult {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
+ mutationFn: ({ mediaId, category, workOrderVersion }: UpdateWorkOrderMediaCategoryInput) =>
+ workOrdersApi.updateMediaCategory(workOrderId, mediaId, category, workOrderVersion),
+ onSuccess: () => {
+ invalidateBoardDetail(queryClient, workOrderId);
+ invalidateWorkOrders(queryClient);
+ toast.success("Category saved");
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update category");
+ },
+ });
+}
+
+export function useDeleteWorkOrderMedia(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ mediaId, workOrderVersion }: DeleteWorkOrderMediaInput) =>
+ workOrdersApi.deleteMedia(workOrderId, mediaId, workOrderVersion),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
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 ca5ee565..a15abc76 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
@@ -1,3 +1,4 @@
+import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
@@ -18,6 +19,7 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
+import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
import { queryKeys } from "@/infra/query-key/query-key";
export type { WorkOrderTablePatch };
@@ -28,6 +30,65 @@ export type WorkOrderTablePatchHandlers = {
getRow?: (id: string | number) => WorkOrderTableRow | undefined;
};
+type PatchResult =
+ { kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
+
+async function applyBoardFieldOps(
+ id: string | number,
+ patch: WorkOrderTablePatch,
+ getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
+): Promise {
+ const ops = expandTablePatchToBoardFieldOps(patch);
+ if (ops.length === 0) {
+ throw new BoardPatchValidationError(
+ "NoPersistableFields",
+ "These changes cannot be saved on the board. Refresh and edit a supported field.",
+ );
+ }
+
+ const current = getRow?.(id);
+ if (!current?.rowVersion) {
+ throw new Error("Missing work order version for board patch. Refresh and retry.");
+ }
+
+ let latest = current;
+ let completedOps = 0;
+ for (const op of ops) {
+ try {
+ latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
+ completedOps += 1;
+ } catch (error) {
+ if (completedOps > 0) {
+ const field = op.field;
+ const partial = new Error(
+ `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
+ error instanceof Error ? error.message : "Unknown error"
+ }`,
+ );
+ (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
+ throw partial;
+ }
+ throw error;
+ }
+ }
+
+ return latest;
+}
+
+/** Board fields (including completedDate) use PATCH …/board — never legacy EditWorkorder. */
+export async function runWorkOrderTablePatch(
+ id: string | number,
+ patch: WorkOrderTablePatch,
+ getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
+): Promise {
+ if (isLocalOnlyTablePatch(patch)) {
+ return { kind: "local", patch };
+ }
+
+ const boardRow = await applyBoardFieldOps(id, patch, getRow);
+ return { kind: "board", row: boardRow };
+}
+
export function useWorkOrderTableMutations(
onPatchOrHandlers:
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
@@ -44,53 +105,15 @@ export function useWorkOrderTableMutations(
const { onPatch, clearPatch, getRow } = handlers;
const queryClient = useQueryClient();
+ const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
};
const patchMutation = useMutation({
- mutationFn: async ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => {
- if (isLocalOnlyTablePatch(patch)) {
- return { kind: "local" as const, patch };
- }
-
- const ops = expandTablePatchToBoardFieldOps(patch);
- if (ops.length === 0) {
- throw new BoardPatchValidationError(
- "NoPersistableFields",
- "These changes cannot be saved on the board. Refresh and edit a supported field.",
- );
- }
-
- const current = getRow?.(id);
- if (!current?.rowVersion) {
- throw new Error("Missing work order version for board patch. Refresh and retry.");
- }
-
- let latest = current;
- let completedOps = 0;
- for (const op of ops) {
- try {
- latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
- completedOps += 1;
- } catch (error) {
- if (completedOps > 0) {
- const field = op.field;
- const partial = new Error(
- `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
- error instanceof Error ? error.message : "Unknown error"
- }`,
- );
- (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
- throw partial;
- }
- throw error;
- }
- }
-
- return { kind: "board" as const, row: latest };
- },
+ mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
+ enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
},
@@ -123,12 +146,29 @@ export function useWorkOrderTableMutations(
},
});
- const patchField = (id: string | number, patch: WorkOrderTablePatch) => {
- patchMutation.mutate({ id, patch });
+ const patchField = (
+ id: string | number,
+ patch: WorkOrderTablePatch,
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ },
+ ) => {
+ patchMutation.mutate(
+ { id, patch },
+ {
+ onSuccess: () => options?.onSuccess?.(),
+ onError: (error) => options?.onError?.(error),
+ },
+ );
};
+ const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
+ patchMutation.mutateAsync({ id, patch });
+
return {
patchField,
+ patchFieldAsync,
isPending: patchMutation.isPending,
};
}
diff --git a/src/domain/work-orders/utils/completion-doc-service-gate.ts b/src/domain/work-orders/utils/completion-doc-service-gate.ts
new file mode 100644
index 00000000..a92c0baf
--- /dev/null
+++ b/src/domain/work-orders/utils/completion-doc-service-gate.ts
@@ -0,0 +1,4 @@
+/** CompDoc is gated on a selected primary service (`pm`). */
+export function hasCompletionDocService(pm: string | null | undefined): boolean {
+ return Boolean(pm?.trim());
+}
diff --git a/src/domain/work-orders/utils/completion-doc-service-title.ts b/src/domain/work-orders/utils/completion-doc-service-title.ts
index 6df69e70..decf73f9 100644
--- a/src/domain/work-orders/utils/completion-doc-service-title.ts
+++ b/src/domain/work-orders/utils/completion-doc-service-title.ts
@@ -17,15 +17,12 @@ const SERVICE_PHRASE: Record = {
"Assess Window Leaks": "Window Leak Assessment",
};
-const FREQ_CYCLE = ["13 Week", "26 Week", "52 Week"];
-
-function hashNum(s: string, mod: number): number {
- let h = 0;
- for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
- return h % mod;
-}
-
-export function serviceTitle(row: Pick): string {
- const phrase = SERVICE_PHRASE[row.pm] ?? row.pm;
- return `Amer-Building ${phrase} — ${FREQ_CYCLE[hashNum(row.woNumber, FREQ_CYCLE.length)]}`;
+/**
+ * Title derived only from the selected service (`pm`).
+ * Never fabricates maintenance-cycle metadata from the work-order number.
+ */
+export function serviceTitle(row: Pick): string {
+ const service = row.pm.trim();
+ const phrase = SERVICE_PHRASE[service] ?? service;
+ return `Amer-Building ${phrase}`;
}
diff --git a/src/domain/work-orders/utils/get-inline-create-missing.ts b/src/domain/work-orders/utils/get-inline-create-missing.ts
new file mode 100644
index 00000000..a732e656
--- /dev/null
+++ b/src/domain/work-orders/utils/get-inline-create-missing.ts
@@ -0,0 +1,62 @@
+/** Required fields for board inline create (Schedule On is optional — SH-109). */
+export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
+
+export const INLINE_CREATE_REQ_LABELS: Record = {
+ site: "Site",
+ woNumber: "WO #",
+ pocName: "POC name",
+ pocPhone: "POC phone",
+};
+
+export type InlineCreateDraftFields = {
+ site?: string;
+ locationId?: string | number;
+ woNumber?: string;
+ pocName?: string;
+ pocPhone?: string;
+};
+
+export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] {
+ const miss: InlineCreateReqField[] = [];
+ const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim());
+ if (!hasSite) {
+ miss.push("site");
+ }
+ if (!d.woNumber?.trim()) {
+ miss.push("woNumber");
+ }
+ if (!d.pocName?.trim()) {
+ miss.push("pocName");
+ }
+ if (!d.pocPhone?.trim()) {
+ miss.push("pocPhone");
+ }
+ return miss;
+}
+
+/** Tooltip for Save: "Missing: Site, WO #" or "Save" when ready. */
+export function inlineCreateSaveHint(miss: InlineCreateReqField[]): string {
+ if (miss.length === 0) {
+ return "Save";
+ }
+ return `Missing: ${miss.map((m) => INLINE_CREATE_REQ_LABELS[m]).join(", ")}`;
+}
+
+export function inlineCreateSiteError(miss: InlineCreateReqField[]): string {
+ if (miss.includes("site")) {
+ return "Site is required";
+ }
+ return "Contact name and phone are required";
+}
+
+/** Concrete calendar day/range — week-only does not count (design hasScheduleDate). */
+export function draftHasScheduleDate(d: {
+ scheduledOn?: string;
+ scheduleWeekOnly?: boolean;
+ schedMode?: string;
+}): boolean {
+ if (d.scheduleWeekOnly || d.schedMode === "week") {
+ return false;
+ }
+ return Boolean(d.scheduledOn?.trim());
+}
diff --git a/src/domain/work-orders/utils/run-sequential-uploads.ts b/src/domain/work-orders/utils/run-sequential-uploads.ts
new file mode 100644
index 00000000..e641a944
--- /dev/null
+++ b/src/domain/work-orders/utils/run-sequential-uploads.ts
@@ -0,0 +1,24 @@
+/**
+ * Runs file uploads one-by-one while holding an aggregate pending flag true
+ * until every file has settled (success or failure). Avoids relying on a single
+ * TanStack Mutation `isPending`, which can flip false when a later small file
+ * finishes before an earlier large one.
+ */
+export async function runSequentialUploads(
+ files: readonly TFile[],
+ upload: (file: TFile) => Promise,
+ setPending: (pending: boolean) => void,
+): Promise {
+ if (files.length === 0) {
+ return;
+ }
+
+ setPending(true);
+ try {
+ for (const file of files) {
+ await upload(file);
+ }
+ } finally {
+ setPending(false);
+ }
+}
diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts
index 9c8d897d..2b4a3226 100644
--- a/src/domain/work-orders/utils/slide-over-draft.ts
+++ b/src/domain/work-orders/utils/slide-over-draft.ts
@@ -18,13 +18,13 @@ export const SLIDE_OVER_EDIT_KEYS = [
"targetWeek",
"scheduleWeekOnly",
"dueDate",
+ "completedDate",
"apptTime",
"pm",
"serviceNotes",
"extraServices",
"vendorId",
- "company",
- "tech",
+ // company/tech are display companions; board persists vendor via vendorId only
"status",
] as const satisfies ReadonlyArray;
diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts
index 4c513763..d79f45be 100644
--- a/src/domain/work-orders/utils/work-order-media-limits.ts
+++ b/src/domain/work-orders/utils/work-order-media-limits.ts
@@ -1,6 +1,10 @@
/** Backend RequestSizeLimit for media and completion-doc uploads. */
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
+const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]);
+
+const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
+
export function isWorkOrderFileTooLarge(
file: File,
maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
@@ -8,6 +12,19 @@ export function isWorkOrderFileTooLarge(
return file.size > maxBytes;
}
+export function isWorkOrderMediaTypeAllowed(file: File): boolean {
+ const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
+ const name = file.name.toLowerCase();
+ const dot = name.lastIndexOf(".");
+ const ext = dot >= 0 ? name.slice(dot) : "";
+ const extOk = ALLOWED_EXTENSIONS.has(ext);
+ return mimeOk || extOk;
+}
+
+export function workOrderMediaAcceptAttribute(): string {
+ return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
+}
+
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
}
diff --git a/src/domain/work-orders/utils/work-order-patch-queue.ts b/src/domain/work-orders/utils/work-order-patch-queue.ts
new file mode 100644
index 00000000..caf2e217
--- /dev/null
+++ b/src/domain/work-orders/utils/work-order-patch-queue.ts
@@ -0,0 +1,20 @@
+/**
+ * Serializes async work per work-order id so concurrent patches (e.g. a completion-doc
+ * status save firing alongside a completed-date save) never race two overlapping
+ * get-then-update round trips against the same record. Each call for a given id waits
+ * for the previous one to settle before starting its own fetch-and-update cycle.
+ */
+export function createWorkOrderPatchQueue() {
+ const chains = new Map>();
+
+ return function enqueue(id: string | number, run: () => Promise): Promise {
+ const key = String(id);
+ const previous = chains.get(key) ?? Promise.resolve();
+ const settled = previous.catch(() => undefined).then(run);
+ chains.set(
+ key,
+ settled.catch(() => undefined),
+ );
+ return settled;
+ };
+}
diff --git a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts
new file mode 100644
index 00000000..ccccd7d3
--- /dev/null
+++ b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { buildInlineDraftCreateInput } from "@/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input";
+import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
+import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
+
+describe("buildInlineDraftCreateInput", () => {
+ it("defaults type to PM and omits schedule flags when undated", () => {
+ const draft = {
+ ...EMPTY_TABLE_DRAFT,
+ site: "BK5",
+ locationId: "9",
+ woNumber: "25001",
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ scheduleWeekOnly: true,
+ targetWeek: "",
+ scheduledOn: "",
+ };
+
+ const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
+
+ expect(input.type).toBe("PM");
+ expect(input.scheduleWeekOnly).toBe(false);
+ expect(input.scheduledOn).toBe("");
+ expect(input.targetWeek).toBe("");
+
+ const payload = mapWizardDraftToBoardCreatePayload(input);
+ expect(payload.scheduledDate).toBeUndefined();
+ expect(payload.targetWeek).toBeUndefined();
+ expect(payload.scheduleWeekOnly).toBeUndefined();
+ expect(payload.woNumber).toBe("25001");
+ expect(payload.pocName).toBe("Jane");
+ });
+
+ it("keeps week-only schedule when targetWeek is set", () => {
+ const draft = {
+ ...EMPTY_TABLE_DRAFT,
+ site: "BK5",
+ locationId: "9",
+ woNumber: "25002",
+ type: "Reactive" as const,
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ scheduleWeekOnly: true,
+ targetWeek: "2026-06-22",
+ };
+
+ const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
+ const payload = mapWizardDraftToBoardCreatePayload(input);
+
+ expect(payload.scheduleWeekOnly).toBe(true);
+ expect(payload.targetWeek).toBe("2026-06-22");
+ expect(payload.scheduledDate).toBeUndefined();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx b/src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx
new file mode 100644
index 00000000..f5bf9847
--- /dev/null
+++ b/src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { CompDocCell } from "@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell";
+
+describe("CompDocCell service gate", () => {
+ it("shows a disabled placeholder when no service is selected", () => {
+ const onOpen = vi.fn();
+ render( );
+
+ expect(screen.getByText("Select service")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /draft|view doc|n\/n/i })).not.toBeInTheDocument();
+ });
+
+ it("opens CompDoc when a service is selected", () => {
+ const onOpen = vi.fn();
+ render(
+ ,
+ );
+
+ screen.getByRole("button", { name: /draft/i }).click();
+ expect(onOpen).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
new file mode 100644
index 00000000..51ae2771
--- /dev/null
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
@@ -0,0 +1,230 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
+ useLocationDetail: () => ({ data: undefined }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
+ useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
+ useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+vi.mock("react-toastify", () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
+
+vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({
+ showCompDocDraftSavedToast: vi.fn(),
+}));
+
+const SCHEDULED_ON = "2026-06-01";
+
+function baseRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ type: "PM",
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: SCHEDULED_ON,
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "Seattle",
+ locationId: "mock-loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "",
+ company: "Vendor Co",
+ tech: "Tech One",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "Amazon Rep",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "PM — SEA1",
+ priority: "Sev 3",
+ primaryDispatchId: null,
+ rowVersion: "",
+ dispatchRowVersion: "",
+ flagColor: null,
+ isAddOn: false,
+ ...overrides,
+ };
+}
+
+describe("CompDocDialog Save Draft / Generate completion contract", () => {
+ const onSave = vi.fn();
+ const onDateCompletedChange = vi.fn();
+
+ beforeEach(() => {
+ onSave.mockReset();
+ onDateCompletedChange.mockReset();
+ });
+
+ function renderDialog(row: WorkOrderTableRow) {
+ // Mirrors board wiring: only completedDate / docStatus — never scheduledOn or lifecycle.
+ const patches: Array> = [];
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+
+ render(
+
+ {
+ await onSave(data, status);
+ patches.push({ docStatus: status });
+ }}
+ onDateCompletedChange={async (date) => {
+ await onDateCompletedChange(date);
+ patches.push({ completedDate: date });
+ }}
+ />
+ ,
+ );
+
+ return { patches };
+ }
+
+ function changeDateCompleted(value: string) {
+ const dateInput = document.querySelector('input[type="date"]');
+ expect(dateInput).not.toBeNull();
+ fireEvent.change(dateInput!, { target: { value } });
+ }
+
+ it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", async () => {
+ const row = baseRow();
+ const { patches } = renderDialog(row);
+
+ changeDateCompleted("2026-07-15");
+ fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
+
+ await waitFor(() => {
+ expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
+ });
+ expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
+ expect(patches).toEqual([{ completedDate: "2026-07-15" }, { docStatus: "No" }]);
+ expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
+ expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
+ expect(row.scheduledOn).toBe(SCHEDULED_ON);
+ expect(row.status).toBe("Scheduled");
+ });
+
+ it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", async () => {
+ const row = baseRow({ completedDate: "2026-07-01" });
+ const { patches } = renderDialog(row);
+
+ changeDateCompleted("2026-07-20");
+ fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
+
+ await waitFor(() => {
+ expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
+ });
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ generatedAt: expect.any(String) }),
+ "Yes",
+ );
+ expect(patches).toEqual([{ completedDate: "2026-07-20" }, { docStatus: "Yes" }]);
+ expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
+ expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
+ expect(row.scheduledOn).toBe(SCHEDULED_ON);
+ expect(row.status).toBe("Scheduled");
+ });
+
+ it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
+ onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
+ const row = baseRow({ completedDate: "2026-07-01" });
+ renderDialog(row);
+
+ changeDateCompleted("2026-07-20");
+ fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
+
+ await waitFor(() => {
+ expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
+ });
+ expect(onSave).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
+ });
+
+ it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {
+ // Regression for the prior order (status then date): if date fails, Yes must not have been sent.
+ // With prerequisites-first ordering, date failure short-circuits before onSave("Yes").
+ const callOrder: string[] = [];
+ onDateCompletedChange.mockImplementation(async () => {
+ callOrder.push("date");
+ throw new Error("completedDate conflict");
+ });
+ onSave.mockImplementation(async () => {
+ callOrder.push("status");
+ });
+
+ const row = baseRow({ completedDate: "2026-07-01" });
+ renderDialog(row);
+ changeDateCompleted("2026-07-20");
+ fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
+
+ await waitFor(() => {
+ expect(onDateCompletedChange).toHaveBeenCalled();
+ });
+ expect(callOrder).toEqual(["date"]);
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("Save draft persists PM comments notes through onSave", async () => {
+ const row = baseRow();
+ renderDialog(row);
+
+ const notes = screen.getByPlaceholderText(/add findings/i);
+ fireEvent.change(notes, { target: { value: "No issues noted" } });
+ fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ notes: "No issues noted" }),
+ "No",
+ );
+ });
+ });
+
+ it("Mark as not required rolls back UI when persist fails", async () => {
+ onSave.mockRejectedValueOnce(new Error("persist failed"));
+ const row = baseRow();
+ renderDialog(row);
+
+ fireEvent.click(screen.getByRole("button", { name: /mark as not required/i }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(expect.any(Object), "NN");
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /mark as not required/i })).toBeInTheDocument();
+ });
+ expect(screen.queryByRole("button", { name: /^undo$/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx
new file mode 100644
index 00000000..49ced8be
--- /dev/null
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx
@@ -0,0 +1,53 @@
+import { createRef } from "react";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { CompDocDialogFooter } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer";
+
+describe("CompDocDialogFooter exclusive states", () => {
+ const pdfInputRef = createRef();
+ const handlers = {
+ onMarkNotRequired: vi.fn(),
+ onSaveDraft: vi.fn(),
+ onGenerate: vi.fn(),
+ onUndo: vi.fn(),
+ onDownloadPdf: vi.fn(),
+ onUploadPdf: vi.fn(),
+ };
+
+ it("does not render draft actions when a signed PDF is on file while docState is still draft", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByRole("button", { name: /save draft/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /mark as not required/i })).not.toBeInTheDocument();
+ expect(screen.getByText(/document on file/i)).toBeInTheDocument();
+ });
+
+ it("renders only draft actions for a clean draft without attachment", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /save draft/i })).toBeInTheDocument();
+ expect(screen.queryByText(/document on file/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
new file mode 100644
index 00000000..6825ef66
--- /dev/null
+++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
@@ -0,0 +1,115 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const uploadMutateAsync = vi.fn();
+const deleteMutate = vi.fn();
+const categorizeMutate = vi.fn();
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
+ useWorkOrderBoardDetail: () => ({
+ data: {
+ info: { status: "Scheduled" },
+ media: [
+ {
+ id: "11",
+ fileName: "before.jpg",
+ url: "https://example.com/before.jpg",
+ category: "Before",
+ isLegacy: false,
+ },
+ ],
+ comments: [],
+ audit: [],
+ completion: {},
+ },
+ isLoading: false,
+ }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
+ useUploadWorkOrderMedia: () => ({
+ mutate: vi.fn(),
+ mutateAsync: uploadMutateAsync,
+ isPending: false,
+ }),
+ useUpdateWorkOrderMediaCategory: () => ({ mutate: categorizeMutate, isPending: false }),
+ useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
+}));
+
+function baseRow(): WorkOrderTableRow {
+ return {
+ id: 7,
+ woNumber: "WO-7",
+ site: "SEA1",
+ type: "Emergency",
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-06-01",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "Seattle",
+ locationId: "loc-1",
+ pm: "",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "",
+ company: "",
+ tech: "",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 1,
+ title: "Emergency",
+ priority: "Sev 1",
+ primaryDispatchId: null,
+ rowVersion: "",
+ dispatchRowVersion: "",
+ flagColor: null,
+ isAddOn: false,
+ };
+}
+
+describe("MediaUploadModal API wiring", () => {
+ beforeEach(() => {
+ uploadMutateAsync.mockReset();
+ deleteMutate.mockReset();
+ categorizeMutate.mockReset();
+ });
+
+ it("renders persisted media from board detail (refresh-safe)", async () => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("before.jpg")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
+ expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
new file mode 100644
index 00000000..5aeb3179
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
@@ -0,0 +1,149 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ReactNode } from "react";
+import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
+import type { Location } from "@/domain/locations/types/location";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const locationDetailState = vi.hoisted(() => ({
+ data: undefined as Location | undefined,
+}));
+
+vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
+ useLocationDetail: () => ({ data: locationDetailState.data }),
+}));
+
+function baseRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ type: "PM",
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-06-01",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "Seattle",
+ locationId: "loc-real-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "",
+ company: "Vendor Co",
+ tech: "Tech One",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "Amazon Rep",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "PM — SEA1",
+ priority: "Sev 3",
+ primaryDispatchId: null,
+ rowVersion: "",
+ dispatchRowVersion: "",
+ flagColor: null,
+ isAddOn: false,
+ ...overrides,
+ };
+}
+
+function wrapper({ children }: { children: ReactNode }) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return {children} ;
+}
+
+describe("useCompDocDialog address seeding", () => {
+ beforeEach(() => {
+ locationDetailState.data = undefined;
+ });
+
+ it("seeds address from the first authoritative locationDetail without locking on the empty fallback", async () => {
+ const row = baseRow();
+ const { result, rerender } = renderHook(
+ () =>
+ useCompDocDialog({
+ row,
+ open: true,
+ onSave: vi.fn(),
+ }),
+ { wrapper },
+ );
+
+ expect(result.current.editAddress).toBe("");
+
+ locationDetailState.data = {
+ id: "loc-real-1",
+ name: "SEA1",
+ address: "3811 Distribution Dr",
+ city: "Seattle",
+ state: "WA",
+ zipCode: "98101",
+ } as Location;
+
+ rerender();
+
+ await waitFor(() => {
+ expect(result.current.editAddress).toContain("3811 Distribution Dr");
+ });
+ });
+
+ it("does not overwrite address after the dispatcher edits it", async () => {
+ locationDetailState.data = {
+ id: "loc-real-1",
+ name: "SEA1",
+ address: "3811 Distribution Dr",
+ city: "Seattle",
+ state: "WA",
+ zipCode: "98101",
+ } as Location;
+
+ const row = baseRow();
+ const { result, rerender } = renderHook(
+ () =>
+ useCompDocDialog({
+ row,
+ open: true,
+ onSave: vi.fn(),
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.editAddress).toContain("3811 Distribution Dr");
+ });
+
+ act(() => {
+ result.current.setEditAddress("Dispatcher typed address");
+ });
+
+ locationDetailState.data = {
+ id: "loc-real-1",
+ name: "SEA1",
+ address: "999 Other St",
+ city: "Seattle",
+ state: "WA",
+ zipCode: "98101",
+ } as Location;
+ rerender();
+
+ expect(result.current.editAddress).toBe("Dispatcher typed address");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx b/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx
new file mode 100644
index 00000000..4ac689fc
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx
@@ -0,0 +1,206 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
+
+function makeFile(name = "photo.jpg") {
+ return new File(["bytes"], name, { type: "image/jpeg" });
+}
+
+describe("useMediaUploadSession retry / cancel", () => {
+ it("retry upserts the same localId instead of appending a duplicate", async () => {
+ let rejectFirst!: (error: Error) => void;
+ const uploadMutateAsync = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectFirst = reject;
+ }),
+ )
+ .mockImplementation(() => new Promise(() => undefined));
+
+ const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
+ const localId = "local-1";
+ const file = makeFile();
+
+ act(() => {
+ result.current.startUploads([
+ {
+ localId,
+ file,
+ onProgress: () => undefined,
+ },
+ ]);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(1);
+ });
+
+ const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
+
+ act(() => {
+ rejectFirst(new Error("network"));
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
+ });
+
+ act(() => {
+ result.current.retryUpload(localId);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(1);
+ expect(result.current.uploads[0]?.id).toBe(localId);
+ expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
+ });
+ expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
+ const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
+ expect(retrySignal).toBeInstanceOf(AbortSignal);
+ expect(retrySignal).not.toBe(firstSignal);
+ expect(retrySignal.aborted).toBe(false);
+ });
+
+ it("fail → retry → cancel aborts the replacement upload and records no success", async () => {
+ let rejectFirst!: (error: Error) => void;
+ let resolveRetry!: (value: unknown) => void;
+ const uploadMutateAsync = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectFirst = reject;
+ }),
+ )
+ .mockImplementation(
+ ({ signal }: { signal?: AbortSignal }) =>
+ new Promise((resolve, reject) => {
+ resolveRetry = resolve;
+ signal?.addEventListener("abort", () => {
+ const error = new Error("aborted");
+ error.name = "AbortError";
+ reject(error);
+ });
+ }),
+ );
+
+ const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
+ const localId = "local-retry";
+ const file = makeFile("retry.jpg");
+
+ act(() => {
+ result.current.startUploads([
+ {
+ localId,
+ file,
+ onProgress: () => undefined,
+ },
+ ]);
+ });
+
+ act(() => {
+ rejectFirst(new Error("upload failed"));
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
+ });
+
+ act(() => {
+ result.current.retryUpload(localId);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
+ });
+
+ const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
+
+ act(() => {
+ result.current.cancelUpload(localId);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(0);
+ });
+ expect(retrySignal.aborted).toBe(true);
+ // Cancelling must not leave a successful settle path.
+ expect(() => resolveRetry({})).not.toThrow();
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(0);
+ });
+ });
+
+ it("clears local uploads and aborts in-flight work when the bound WO changes", async () => {
+ const uploadMutateAsync = vi.fn().mockImplementation(
+ ({ signal }: { signal?: AbortSignal }) =>
+ new Promise((_resolve, reject) => {
+ signal?.addEventListener("abort", () => {
+ const error = new Error("aborted");
+ error.name = "AbortError";
+ reject(error);
+ });
+ }),
+ );
+
+ const detailA = {
+ info: { id: 1 },
+ media: [],
+ comments: [],
+ audit: [],
+ completion: {
+ docStatus: "",
+ template: null,
+ signOffName: "",
+ signOffAttachment: "",
+ signOffSignature: "",
+ dispatchSignoffs: [],
+ },
+ } as unknown as Parameters[0];
+
+ const detailB = {
+ info: { id: 2 },
+ media: [],
+ comments: [],
+ audit: [],
+ completion: {
+ docStatus: "",
+ template: null,
+ signOffName: "",
+ signOffAttachment: "",
+ signOffSignature: "",
+ dispatchSignoffs: [],
+ },
+ } as unknown as Parameters[0];
+
+ const { result, rerender } = renderHook(
+ ({ detail }) => useMediaUploadSession(detail, uploadMutateAsync),
+ { initialProps: { detail: detailA } },
+ );
+
+ act(() => {
+ result.current.startUploads([
+ {
+ localId: "local-wo-a",
+ file: makeFile("a.jpg"),
+ onProgress: () => undefined,
+ },
+ ]);
+ });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(1);
+ });
+
+ const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
+
+ rerender({ detail: detailB });
+
+ await waitFor(() => {
+ expect(result.current.uploads).toHaveLength(0);
+ });
+ expect(firstSignal.aborted).toBe(true);
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
new file mode 100644
index 00000000..dc0adb78
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
@@ -0,0 +1,112 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
+import type { Location } from "@/domain/locations/types/location";
+import type { LocationOption } from "@/domain/work-orders/types/work-order";
+
+const detailById = new Map();
+
+vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
+ useLocationDetail: (id: string | undefined) => ({
+ data: id && detailById.has(id) ? detailById.get(id) : undefined,
+ isLoading: Boolean(id && !detailById.has(id)),
+ isError: false,
+ }),
+}));
+
+const SITES: LocationOption[] = [
+ { id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
+ { id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },
+];
+
+function locationDetail(id: string, contact: string, phone: string): Location {
+ return {
+ id,
+ name: id,
+ contact,
+ phone,
+ address: "100 Main St",
+ city: "Dallas",
+ state: "TX",
+ zipCode: "75001",
+ };
+}
+
+function renderSiteDialogState() {
+ return renderHook(() =>
+ useSiteDialogState({
+ open: true,
+ onOpenChange: vi.fn(),
+ value: "",
+ locationId: "",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ sites: SITES,
+ createMode: true,
+ onSave: vi.fn(),
+ }),
+ );
+}
+
+describe("useSiteDialogState POC autofill", () => {
+ beforeEach(() => {
+ detailById.clear();
+ });
+
+ it("preserves POC typed before deferred location detail resolves", () => {
+ const { result, rerender } = renderSiteDialogState();
+
+ act(() => {
+ result.current.handlePick("loc-1");
+ });
+
+ act(() => {
+ result.current.setPn("Dispatcher Typed");
+ result.current.setPp("999-000-1111");
+ });
+
+ expect(result.current.pn).toBe("Dispatcher Typed");
+ expect(result.current.pp).toBe("999-000-1111");
+
+ detailById.set("loc-1", locationDetail("loc-1", "Site Contact", "214-555-0111"));
+ act(() => {
+ rerender();
+ });
+
+ expect(result.current.pn).toBe("Dispatcher Typed");
+ expect(result.current.pp).toBe("999-000-1111");
+ });
+
+ it("clears prior POC on site switch then autofills from the new site detail", () => {
+ const { result, rerender } = renderSiteDialogState();
+
+ act(() => {
+ result.current.handlePick("loc-1");
+ });
+
+ detailById.set("loc-1", locationDetail("loc-1", "Contact A", "111-111-1111"));
+ act(() => {
+ rerender();
+ });
+
+ expect(result.current.pn).toBe("Contact A");
+ expect(result.current.pp).toBe("111-111-1111");
+
+ act(() => {
+ result.current.handlePick("loc-2");
+ });
+
+ expect(result.current.locId).toBe("loc-2");
+ expect(result.current.pn).toBe("");
+ expect(result.current.pp).toBe("");
+
+ detailById.set("loc-2", locationDetail("loc-2", "Contact B", "222-222-2222"));
+ act(() => {
+ rerender();
+ });
+
+ expect(result.current.pn).toBe("Contact B");
+ expect(result.current.pp).toBe("222-222-2222");
+ });
+});
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
new file mode 100644
index 00000000..861e5870
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
@@ -0,0 +1,143 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
+import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+function baseInfo(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 100,
+ woNumber: "100",
+ site: "SITE",
+ type: "Emergency",
+ dispatcherId: "d1",
+ dispatcherName: "Alex",
+ scheduledOn: "2026-07-16",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "2026-07-16",
+ dayLabel: "Thu",
+ location: "Main",
+ locationId: 5,
+ pm: "",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "v1",
+ company: "Acme",
+ tech: "Sam",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Incomplete",
+ docStatus: "",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ isAddOn: false,
+ ...overrides,
+ };
+}
+
+function detailWithMedia(
+ media: WorkOrderBoardDetail["media"],
+ infoOverrides: Partial = {},
+): WorkOrderBoardDetail {
+ return {
+ info: baseInfo(infoOverrides),
+ completion: {
+ docStatus: "",
+ template: null,
+ signOffName: "",
+ signOffAttachment: "",
+ signOffSignature: "",
+ dispatchSignoffs: [],
+ },
+ comments: [],
+ audit: [],
+ media,
+ };
+}
+
+function idleMutation() {
+ return {
+ mutate: vi.fn(),
+ mutateAsync: vi.fn().mockResolvedValue({}),
+ isPending: false,
+ } as never;
+}
+
+describe("useSlideOverMediaState completion filter", () => {
+ it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
+ const detail = detailWithMedia([
+ {
+ id: "1",
+ category: "Before",
+ url: "https://cdn.example.com/before.jpg",
+ fileName: "before.jpg",
+ uploadedAt: "2026-07-15T18:00:00.0000000Z",
+ isLegacy: false,
+ },
+ {
+ id: "2",
+ category: "After",
+ url: "https://cdn.example.com/after.jpg",
+ fileName: "after.jpg",
+ uploadedAt: "2026-07-15T18:00:00.0000000Z",
+ isLegacy: false,
+ },
+ {
+ id: "3",
+ category: "Completion",
+ url: "https://cdn.example.com/completion.pdf",
+ fileName: "completion.pdf",
+ 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,
+ },
+ ]);
+
+ const { result } = renderHook(() =>
+ useSlideOverMediaState({
+ detailForRow: detail,
+ uploadMediaMutation: idleMutation(),
+ deleteMediaMutation: idleMutation(),
+ categorizeMediaMutation: idleMutation(),
+ }),
+ );
+
+ expect(result.current.completionMedia.map((item) => item.purpose)).toEqual([
+ "Before",
+ "After",
+ "Completion",
+ ]);
+ expect(
+ result.current.completionMedia.every((item) =>
+ ["Photo", "Video", "Report", "Other"].includes(item.category),
+ ),
+ ).toBe(true);
+ expect(result.current.extrasUploads).toHaveLength(1);
+ expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
index 9abd8aa1..2c5bc61a 100644
--- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
+++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
@@ -17,6 +17,7 @@ const unscheduledRow: WorkOrderTableRow = {
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "",
+ completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
new file mode 100644
index 00000000..54383c13
--- /dev/null
+++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
@@ -0,0 +1,172 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+vi.mock("@/providers/auth-context", () => ({
+ useAuthContext: () => ({ user: { id: "u1" } }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
+ useWorkOrderBoardDetail: () => ({
+ data: undefined,
+ isLoading: true,
+ isFetching: true,
+ error: null,
+ refetch: vi.fn(),
+ }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
+ useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
+ useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
+ useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }),
+ useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }),
+ useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }),
+ useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+vi.mock("@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab", () => ({
+ SlideOverInfoTab: ({
+ draft,
+ onDraftChange,
+ }: {
+ draft: WorkOrderTableRow;
+ onDraftChange: (patch: Partial) => void;
+ }) => (
+
+ WO number
+ onDraftChange({ woNumber: event.target.value })}
+ />
+
+ ),
+}));
+
+vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
+
+function baseRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ type: "PM",
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-07-20",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "07/20",
+ dayLabel: "Mon",
+ location: "Seattle",
+ locationId: "loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ company: "Vendor Co",
+ tech: "Tech",
+ techPhone: "",
+ vendorId: "v1",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "PM — SEA1",
+ priority: "Sev 3",
+ primaryDispatchId: null,
+ rowVersion: "v1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ isAddOn: false,
+ ...overrides,
+ };
+}
+
+describe("WorkOrderSlideOver failed-save retry (no detail yet)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("keeps draft dirty and Save enabled after optimistic parent row + failed save", async () => {
+ const initial = baseRow();
+ let currentRow = initial;
+ const onSave = vi.fn(
+ (
+ _id: string | number,
+ patch: Partial,
+ options?: { onSuccess?: () => void; onError?: (error: Error) => void },
+ ) => {
+ currentRow = { ...currentRow, ...patch };
+ rerenderWithRow(currentRow);
+ currentRow = initial;
+ rerenderWithRow(currentRow);
+ options?.onError?.(new Error("save failed"));
+ },
+ );
+
+ let rerenderWithRow = (_row: WorkOrderTableRow) => {
+ /* assigned after first render */
+ };
+
+ const view = render(
+ ,
+ );
+
+ rerenderWithRow = (row) => {
+ view.rerender(
+ ,
+ );
+ };
+
+ fireEvent.change(screen.getByLabelText("WO number"), { target: { value: "WO-99" } });
+ expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
+
+ const saveButton = screen.getByRole("button", { name: /^save$/i });
+ expect(saveButton).not.toBeDisabled();
+ fireEvent.click(saveButton);
+
+ expect(onSave).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^save$/i })).not.toBeDisabled();
+ });
+ });
+});
diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
index ab94481a..a67b9833 100644
--- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
@@ -81,6 +81,13 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderVendors: () => ({
data: [{ companyName: "Vendor Co", email: "real@vendor.com" }],
}),
+ useWorkOrderBoardDetail: () => ({
+ data: undefined,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ refetch: vi.fn(),
+ }),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
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 3ab80e4b..a4b72e05 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
@@ -75,6 +75,7 @@ const canceledRow: WorkOrderTableRow = {
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "2026-07-20",
+ completedDate: "",
targetWeek: "2026-07-20",
dayGroup: "2026-07-20",
dayLabel: "Mon",
@@ -135,6 +136,13 @@ vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderLocations: () => ({ data: [] }),
useWorkOrderVendors: () => ({ data: [] }),
+ useWorkOrderBoardDetail: () => ({
+ data: undefined,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ refetch: vi.fn(),
+ }),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
@@ -198,7 +206,7 @@ describe("WorkOrdersListHeader slice affordances", () => {
});
describe("WorkOrdersListPage board-core public interface", () => {
- it("opens New WO wizard on click, enforces step validation, closes, and View navigates to detail", async () => {
+ it("opens New WO wizard on click, enforces step validation, closes, and View opens the slide-over", async () => {
navigate.mockReset();
renderWithProviders( , { route: "/workorders", withAuth: false });
@@ -217,7 +225,10 @@ describe("WorkOrdersListPage board-core public interface", () => {
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));
- expect(navigate).toHaveBeenCalledWith("/workorders/77");
+
+ expect(await screen.findByLabelText("Work order details")).toBeInTheDocument();
+ expect(screen.getAllByText(/WO-77/i).length).toBeGreaterThan(0);
+ expect(navigate).not.toHaveBeenCalled();
});
});
@@ -234,7 +245,13 @@ describe("CompDocCell slice affordances", () => {
it("shows View Doc when onOpen is provided", () => {
renderWithProviders(
- ,
+ ,
{ withAuth: false },
);
diff --git a/src/test/app/v/dispatch-snapshot-details.test.tsx b/src/test/app/v/dispatch-snapshot-details.test.tsx
new file mode 100644
index 00000000..66f31610
--- /dev/null
+++ b/src/test/app/v/dispatch-snapshot-details.test.tsx
@@ -0,0 +1,52 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { DispatchSnapshotDetails } from "@/app/v/[token]/dispatch/_components/dispatch-snapshot-details";
+import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
+import { renderWithProviders } from "@/test/test-utils";
+
+function buildData(
+ overrides: Partial = {},
+): VendorPortalDispatchDetail {
+ return {
+ id: 9,
+ dispatchNumber: "DSP-9",
+ status: "Verified",
+ nteAmount: 500,
+ checklist: [],
+ comments: [],
+ upliftRequests: [],
+ signoffs: [],
+ documents: [],
+ ...overrides,
+ };
+}
+
+describe("DispatchSnapshotDetails locked banner", () => {
+ it.each(["Verified", "Cancelled", "Refused"])(
+ "states that comments remain open when status is %s",
+ (status) => {
+ renderWithProviders( , {
+ withAuth: false,
+ });
+
+ expect(
+ screen.getByText(
+ new RegExp(
+ `This dispatch is ${status.toLowerCase()} and locked to further changes\\. Comments remain open\\.`,
+ "i",
+ ),
+ ),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/sign-off/i)).not.toBeInTheDocument();
+ },
+ );
+
+ it("hides the locked banner when the dispatch is not locked", () => {
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ expect(screen.queryByText(/locked to further changes/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts
index f641daa6..54ae3a76 100644
--- a/src/test/domain/work-orders/api/work-orders-api.test.ts
+++ b/src/test/domain/work-orders/api/work-orders-api.test.ts
@@ -165,6 +165,13 @@ describe("workOrdersApi.update", () => {
});
});
+describe("workOrdersApi completedDate board contract", () => {
+ it("does not expose updateCompletedDate — completedDate uses board PATCH", () => {
+ expect("updateCompletedDate" in workOrdersApi).toBe(false);
+ expect(API_PATHS.workOrder.boardPatch(55)).toBe("workorders/55/board");
+ });
+});
+
describe("workOrdersApi.fetchDispatchers", () => {
beforeEach(() => {
apiGet.mockReset();
@@ -497,12 +504,55 @@ describe("workOrdersApi.uploadMedia", () => {
});
});
+describe("workOrdersApi.updateMediaCategory", () => {
+ beforeEach(() => {
+ apiPatchFn.mockReset();
+ });
+
+ it("PATCHes FormData with category and workOrderVersion", async () => {
+ apiPatchFn.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ id: 12,
+ category: 1,
+ url: "https://cdn.example.com/before.jpg",
+ fileName: "before.jpg",
+ uploadedAt: "2026-07-16T12:00:00Z",
+ isLegacy: false,
+ }),
+ });
+
+ const item = await workOrdersApi.updateMediaCategory(10, 12, "Before", "AAAAAAA=");
+
+ expect(apiPatchFn).toHaveBeenCalledWith(
+ API_PATHS.workOrder.mediaItem(10, 12),
+ expect.objectContaining({ throwHttpErrors: false }),
+ );
+ const body = apiPatchFn.mock.calls[0]?.[1]?.body as FormData;
+ expect(body.get("category")).toBe("Before");
+ expect(body.get("workOrderVersion")).toBe("AAAAAAA=");
+ expect(item.category).toBe("Before");
+ });
+
+ it("rejects missing workOrderVersion client-side", async () => {
+ const { BoardPatchValidationError } =
+ await import("@/domain/work-orders/errors/board-patch-errors");
+
+ await expect(workOrdersApi.updateMediaCategory(10, 12, "After", "")).rejects.toMatchObject({
+ name: "BoardPatchValidationError",
+ code: "WorkOrderVersionRequired",
+ });
+ expect(apiPatchFn).not.toHaveBeenCalled();
+ expect(BoardPatchValidationError).toBeTruthy();
+ });
+});
+
describe("workOrdersApi.deleteMedia", () => {
beforeEach(() => {
apiDeleteFn.mockReset();
});
- it("DELETEs media item and accepts 204", async () => {
+ it("DELETEs media item with workOrderVersion query and accepts 204", async () => {
apiDeleteFn.mockResolvedValue({
ok: true,
status: 204,
@@ -511,18 +561,31 @@ describe("workOrdersApi.deleteMedia", () => {
},
});
- await workOrdersApi.deleteMedia(10, 12);
+ await workOrdersApi.deleteMedia(10, 12, "AAAAAAA=");
expect(apiDeleteFn).toHaveBeenCalledWith(API_PATHS.workOrder.mediaItem(10, 12), {
+ searchParams: { workOrderVersion: "AAAAAAA=" },
throwHttpErrors: false,
});
});
+ it("rejects missing workOrderVersion client-side", async () => {
+ const { BoardPatchValidationError } =
+ await import("@/domain/work-orders/errors/board-patch-errors");
+
+ await expect(workOrdersApi.deleteMedia(10, 12, "")).rejects.toMatchObject({
+ name: "BoardPatchValidationError",
+ code: "WorkOrderVersionRequired",
+ });
+ expect(apiDeleteFn).not.toHaveBeenCalled();
+ expect(BoardPatchValidationError).toBeTruthy();
+ });
+
it("rejects legacy media ids client-side", async () => {
const { BoardPatchValidationError } =
await import("@/domain/work-orders/errors/board-patch-errors");
- await expect(workOrdersApi.deleteMedia(10, -1)).rejects.toBeInstanceOf(
+ await expect(workOrdersApi.deleteMedia(10, -1, "AAAAAAA=")).rejects.toBeInstanceOf(
BoardPatchValidationError,
);
expect(apiDeleteFn).not.toHaveBeenCalled();
diff --git a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts
new file mode 100644
index 00000000..9b74c71a
--- /dev/null
+++ b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { WO_TABLE_COLS } from "@/app/(protected)/workorders/_components/list/table/wo-table-cols";
+import { expandScalarBoardOps } from "@/domain/work-orders/mappers/expand-scalar-board-ops";
+import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
+
+describe("COMPLETED DATE board column wiring", () => {
+ it("includes COMPLETED DATE column after COMP DOC", () => {
+ const keys = WO_TABLE_COLS.map((col) => col.key);
+ expect(keys).toContain("completed");
+ expect(keys.indexOf("completed")).toBeGreaterThan(keys.indexOf("doc"));
+ });
+
+ it("expands completedDate patch to board field op", () => {
+ const ops = expandScalarBoardOps({ completedDate: "2026-07-30" });
+ expect(ops).toEqual([{ field: "completedDate", value: "2026-07-30" }]);
+ });
+
+ it("maps completedDate returned by a board GET row", () => {
+ const row = mapWorkOrderTableRow({
+ id: 42,
+ completedDate: "2026-07-30T00:00:00Z",
+ });
+
+ expect(row.completedDate).toBe("2026-07-30");
+ });
+});
diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
index 350df95f..1bf399ce 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
@@ -122,6 +122,8 @@ describe("mapWorkOrderBoardDetail", () => {
expect(detail.info.docStatus).toBe("No");
expect(detail.info.company).toBe("ACME HVAC");
expect(detail.info.tech).toBe("Mike Tech");
+ expect(detail.info.pm).toBe("HVAC PM");
+ expect(detail.info.location).toBe("Main Warehouse");
expect(detail.completion.docStatus).toBe("No");
expect(detail.completion.template?.name).toBe("HVAC PM Completion");
@@ -170,13 +172,40 @@ describe("mapWorkOrderBoardDetail", () => {
name: "before.jpg",
url: "https://cdn.example.com/before.jpg",
category: "Photo",
+ purpose: "Before",
fileType: "image",
isLegacy: false,
});
expect(files[1]?.category).toBe("Other");
+ expect(files[1]?.purpose).toBe("Extra");
expect(files[1]?.fileType).toBe("pdf");
});
+ it("maps After and Completion board categories to purpose for completion UI", () => {
+ const after = mapBoardDetailMediaItem({
+ id: 20,
+ category: 2,
+ url: "https://cdn.example.com/after.jpg",
+ fileName: "after.jpg",
+ });
+ const completion = mapBoardDetailMediaItem({
+ id: 21,
+ category: 4,
+ url: "https://cdn.example.com/completion.pdf",
+ fileName: "completion.pdf",
+ });
+ const files = boardMediaItemsToMediaFiles([after, completion]);
+ expect(files[0]).toMatchObject({
+ category: "Photo",
+ purpose: "After",
+ });
+ expect(files[1]).toMatchObject({
+ category: "Report",
+ purpose: "Completion",
+ fileType: "pdf",
+ });
+ });
+
it("maps numeric category and marks negative ids as legacy", () => {
const legacy = mapBoardDetailMediaItem({
id: -1,
diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
index cc19b3a5..f295d012 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
@@ -250,6 +250,21 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.scheduledDate).toBeUndefined();
});
+ it("omits week-mode flags when scheduleWeekOnly is set without a week", () => {
+ const payload = mapWizardDraftToBoardCreatePayload({
+ ...EMPTY_WIZARD_DRAFT,
+ type: "PM",
+ siteCode: "BK5",
+ scheduleWeekOnly: true,
+ scheduledOn: "",
+ targetWeek: "",
+ });
+
+ expect(payload.scheduleWeekOnly).toBeUndefined();
+ expect(payload.targetWeek).toBeUndefined();
+ expect(payload.scheduledDate).toBeUndefined();
+ });
+
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
diff --git a/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts b/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts
index 5b71e223..ed1be20e 100644
--- a/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts
@@ -135,4 +135,18 @@ describe("mapWorkOrderFormToUpdateFormData", () => {
expect(formData.getAll("ContactIds")).toEqual([]);
expect(formData.getAll("CategoryIds")).toEqual([]);
});
+
+ it("keeps completedDate on full EditWorkorder form and omits DocStatus (board field)", () => {
+ const values = {
+ ...createDefaultWorkOrderFormValues(),
+ createdDate: "2026-01-15",
+ completedDate: "2026-07-20",
+ docStatus: "NN" as const,
+ };
+
+ const formData = mapWorkOrderFormToUpdateFormData(1, values);
+
+ expect(formData.get("CompletedDate")).toBe("2026-07-20");
+ expect(formData.has("DocStatus")).toBe(false);
+ });
});
diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
index 63a92912..1dd43618 100644
--- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
+ mapDocStatusToApi,
+ mapWorkOrderDetail,
+ mapWorkOrderDetailToFormValues,
mapWorkOrderDispatchSummary,
mapWorkOrderTableRow,
} from "@/domain/work-orders/mappers/work-order-mapper";
@@ -23,6 +26,40 @@ describe("mapWorkOrderDispatchSummary", () => {
});
});
+describe("mapWorkOrderDetail completion-document status", () => {
+ it("reads DocStatus from the API payload so it round-trips into form values", () => {
+ const detail = mapWorkOrderDetail({
+ Id: 42,
+ CompletedDate: "2026-07-20",
+ DocStatus: "Yes",
+ });
+
+ expect(detail.docStatus).toBe("Yes");
+
+ const formValues = mapWorkOrderDetailToFormValues(detail);
+ expect(formValues.docStatus).toBe("Yes");
+ expect(formValues.completedDate).toBe("2026-07-20");
+ });
+
+ it("maps API NotNeeded to UI NN", () => {
+ expect(mapWorkOrderDetail({ Id: 1, DocStatus: "NotNeeded" }).docStatus).toBe("NN");
+ expect(mapWorkOrderDetail({ Id: 2, docStatus: 3 }).docStatus).toBe("NN");
+ });
+
+ it("falls back to an empty doc status for unknown/missing values", () => {
+ const detail = mapWorkOrderDetail({ Id: 1 });
+ expect(detail.docStatus).toBe("");
+ });
+});
+
+describe("mapDocStatusToApi", () => {
+ it("maps NN to NotNeeded and keeps Yes/No", () => {
+ expect(mapDocStatusToApi("NN")).toBe("NotNeeded");
+ expect(mapDocStatusToApi("Yes")).toBe("Yes");
+ expect(mapDocStatusToApi("No")).toBe("No");
+ });
+});
+
describe("mapUpliftRequest", () => {
it("maps decided uplift fields from PascalCase payload", () => {
const result = mapUpliftRequest({
diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts
new file mode 100644
index 00000000..b9951795
--- /dev/null
+++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts
@@ -0,0 +1,50 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const patchBoardField = vi.fn();
+
+vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
+ workOrdersApi: {
+ getById: vi.fn(),
+ update: vi.fn(),
+ patchBoardField: (...args: unknown[]) => patchBoardField(...args),
+ },
+}));
+
+import { runWorkOrderTablePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
+
+const BASE_ROW = {
+ id: 99,
+ rowVersion: "AAAAAAA=",
+ completedDate: "",
+} as WorkOrderTableRow;
+
+describe("runWorkOrderTablePatch completedDate", () => {
+ beforeEach(() => {
+ patchBoardField.mockReset();
+ patchBoardField.mockResolvedValue({
+ ...BASE_ROW,
+ completedDate: "2026-07-30",
+ rowVersion: "BBBBBBB=",
+ });
+ });
+
+ it("persists completedDate via board PATCH (not EditWorkorder)", async () => {
+ const result = await runWorkOrderTablePatch(
+ 99,
+ { completedDate: "2026-07-30" },
+ () => BASE_ROW,
+ );
+
+ expect(patchBoardField).toHaveBeenCalledTimes(1);
+ expect(patchBoardField).toHaveBeenCalledWith(99, {
+ field: "completedDate",
+ value: "2026-07-30",
+ workOrderVersion: "AAAAAAA=",
+ });
+ expect(result).toEqual({
+ kind: "board",
+ row: expect.objectContaining({ completedDate: "2026-07-30" }),
+ });
+ });
+});
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 275f7b79..6ce07630 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
@@ -42,12 +42,14 @@ function makeWrapper() {
type ServerState = {
status: string;
scheduledDate: string;
+ completedDate: string;
docStatus: CompletionDocStatus;
};
const BASE_SERVER: ServerState = {
status: "Open",
scheduledDate: "2026-06-01",
+ completedDate: "",
docStatus: "No",
};
@@ -59,6 +61,7 @@ const BASE_ROW = {
dispatcherId: "d1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01",
+ completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
@@ -126,6 +129,14 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
rowVersion: `${payload.workOrderVersion}-next`,
};
}
+ if (payload.field === "completedDate" && typeof payload.value === "string") {
+ serverState = { ...serverState, completedDate: payload.value };
+ rowState = {
+ ...rowState,
+ completedDate: payload.value,
+ rowVersion: `${payload.workOrderVersion}-date`,
+ };
+ }
events.push(`boardPatch:${payload.field}:end`);
return { ...rowState };
},
@@ -159,6 +170,46 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
expect(getById).not.toHaveBeenCalled();
expect(serverState.docStatus).toBe("NN");
});
+
+ it("serializes board docStatus and completedDate via PATCH without racing or EditWorkorder", async () => {
+ const onPatch = vi.fn();
+ const { result } = renderHook(
+ () =>
+ useWorkOrderTableMutations({
+ onPatch,
+ clearPatch: () => undefined,
+ getRow: () => rowState,
+ }),
+ { wrapper: makeWrapper() },
+ );
+
+ act(() => {
+ result.current.patchField(42, { docStatus: "Yes" });
+ result.current.patchField(42, { completedDate: "2026-07-20" });
+ });
+
+ await waitFor(() => expect(events).toContain("boardPatch:completedDate:end"));
+ await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
+
+ expect(events).toEqual([
+ "boardPatch:docStatus:start",
+ "boardPatch:docStatus:end",
+ "boardPatch:completedDate:start",
+ "boardPatch:completedDate:end",
+ ]);
+
+ expect(getById).not.toHaveBeenCalled();
+ expect(update).not.toHaveBeenCalled();
+ expect(patchBoardField).toHaveBeenCalledWith(
+ 42,
+ expect.objectContaining({ field: "completedDate", value: "2026-07-20" }),
+ );
+
+ expect(serverState.docStatus).toBe("Yes");
+ expect(serverState.completedDate).toBe("2026-07-20");
+ expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
+ expect(serverState.status).toBe(BASE_SERVER.status);
+ });
});
describe("applySchedulePatch isAddOn optimistic clear", () => {
diff --git a/src/test/domain/work-orders/utils/completion-doc-service-title.test.ts b/src/test/domain/work-orders/utils/completion-doc-service-title.test.ts
new file mode 100644
index 00000000..380a28e4
--- /dev/null
+++ b/src/test/domain/work-orders/utils/completion-doc-service-title.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
+import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
+
+describe("completion doc service gate", () => {
+ it("rejects empty or whitespace-only pm", () => {
+ expect(hasCompletionDocService("")).toBe(false);
+ expect(hasCompletionDocService(" ")).toBe(false);
+ expect(hasCompletionDocService(null)).toBe(false);
+ expect(hasCompletionDocService(undefined)).toBe(false);
+ });
+
+ it("accepts a selected service", () => {
+ expect(hasCompletionDocService("HVAC Inspection")).toBe(true);
+ });
+});
+
+describe("completion doc service title", () => {
+ it("uses the selected service without fabricating a frequency cycle", () => {
+ expect(serviceTitle({ pm: "HVAC Inspection" })).toBe("Amer-Building HVAC Inspection");
+ expect(serviceTitle({ pm: "HVAC Inspection" })).not.toMatch(/Week/);
+ });
+
+ it("maps known service phrases without hashing the work-order number", () => {
+ expect(serviceTitle({ pm: "Scrubber Pit" })).toBe("Amer-Building Scrubber Pit Augering");
+ });
+});
diff --git a/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts
new file mode 100644
index 00000000..3ddd8dca
--- /dev/null
+++ b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import {
+ draftHasScheduleDate,
+ getInlineCreateMissing,
+ inlineCreateSaveHint,
+ inlineCreateSiteError,
+} from "@/domain/work-orders/utils/get-inline-create-missing";
+
+describe("getInlineCreateMissing", () => {
+ it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
+ expect(
+ getInlineCreateMissing({
+ site: "BK5",
+ locationId: "12",
+ woNumber: "25001",
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ }),
+ ).toEqual([]);
+ });
+
+ it("accepts locationId alone as site presence", () => {
+ expect(
+ getInlineCreateMissing({
+ site: "",
+ locationId: 42,
+ woNumber: "25001",
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ }),
+ ).toEqual([]);
+ });
+
+ it("lists missing POC fields without requiring schedule", () => {
+ expect(
+ getInlineCreateMissing({
+ site: "BK5",
+ woNumber: "25001",
+ pocName: "",
+ pocPhone: "",
+ }),
+ ).toEqual(["pocName", "pocPhone"]);
+ });
+
+ it("lists site and woNumber when empty", () => {
+ expect(getInlineCreateMissing({})).toEqual(["site", "woNumber", "pocName", "pocPhone"]);
+ });
+});
+
+describe("inlineCreateSaveHint", () => {
+ it("returns Save when ready", () => {
+ expect(inlineCreateSaveHint([])).toBe("Save");
+ });
+
+ it("lists missing labels", () => {
+ expect(inlineCreateSaveHint(["site", "woNumber"])).toBe("Missing: Site, WO #");
+ });
+});
+
+describe("inlineCreateSiteError", () => {
+ it("prefers site-required message when site is missing", () => {
+ expect(inlineCreateSiteError(["site", "pocName"])).toBe("Site is required");
+ });
+
+ it("surfaces contact message when only POC is missing", () => {
+ expect(inlineCreateSiteError(["pocName", "pocPhone"])).toBe(
+ "Contact name and phone are required",
+ );
+ });
+});
+
+describe("draftHasScheduleDate", () => {
+ it("is true for a concrete day", () => {
+ expect(draftHasScheduleDate({ scheduledOn: "2026-06-25" })).toBe(true);
+ });
+
+ it("is false for week-only", () => {
+ expect(
+ draftHasScheduleDate({ scheduledOn: "", scheduleWeekOnly: true, schedMode: "week" }),
+ ).toBe(false);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/run-sequential-uploads.test.ts b/src/test/domain/work-orders/utils/run-sequential-uploads.test.ts
new file mode 100644
index 00000000..92bd2d01
--- /dev/null
+++ b/src/test/domain/work-orders/utils/run-sequential-uploads.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it, vi } from "vitest";
+import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
+
+describe("runSequentialUploads", () => {
+ it("keeps pending true across the full batch; second upload starts only after the first settles", async () => {
+ const pendingLog: boolean[] = [];
+ const setPending = (pending: boolean) => {
+ pendingLog.push(pending);
+ };
+
+ let resolveFirst: (() => void) | undefined;
+ let resolveSecond: (() => void) | undefined;
+
+ const first = new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+ const second = new Promise((resolve) => {
+ resolveSecond = resolve;
+ });
+
+ const upload = vi
+ .fn()
+ .mockImplementationOnce(() => first)
+ .mockImplementationOnce(() => second);
+
+ const done = runSequentialUploads(["large.bin", "small.bin"], upload, setPending);
+
+ expect(pendingLog).toEqual([true]);
+ expect(upload).toHaveBeenCalledTimes(1);
+ expect(upload).toHaveBeenCalledWith("large.bin");
+
+ // First file still in flight — pending must stay true.
+ await Promise.resolve();
+ expect(pendingLog).toEqual([true]);
+ expect(upload).toHaveBeenCalledTimes(1);
+
+ resolveFirst?.();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(upload).toHaveBeenCalledTimes(2);
+ expect(upload).toHaveBeenNthCalledWith(2, "small.bin");
+ expect(pendingLog).toEqual([true]);
+
+ resolveSecond?.();
+ await done;
+
+ expect(pendingLog).toEqual([true, false]);
+ });
+
+ it("clears pending when a middle upload rejects", async () => {
+ const setPending = vi.fn();
+ const upload = vi
+ .fn()
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(new Error("upload failed"));
+
+ await expect(runSequentialUploads(["a", "b", "c"], upload, setPending)).rejects.toThrow(
+ "upload failed",
+ );
+
+ expect(setPending.mock.calls.map((call) => call[0])).toEqual([true, false]);
+ expect(upload).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
index 04ca3026..3d40e238 100644
--- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts
+++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
@@ -15,6 +15,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
dispatcherId: "10",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",
+ completedDate: "",
targetWeek: "",
dayGroup: "2026-07-16",
dayLabel: "Thu",
@@ -58,9 +59,23 @@ describe("slide-over-draft", () => {
it("exposes only board-persistable edit keys", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
+ expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
+ expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company");
+ expect(SLIDE_OVER_EDIT_KEYS).not.toContain("tech");
+ });
+
+ it("tracks vendor via vendorId only (company/tech are display companions)", () => {
+ const row = baseRow();
+ const draft = baseRow({ company: "Other Co", tech: "Pat" });
+ expect(isSlideOverDraftDirty(draft, row)).toBe(false);
+ expect(buildSlideOverPatch(draft, row)).toEqual({});
+
+ const vendorDraft = baseRow({ vendorId: "v2", company: "Other Co", tech: "Pat" });
+ expect(isSlideOverDraftDirty(vendorDraft, row)).toBe(true);
+ expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
});
it("ignores POC-only edits for dirty state", () => {
diff --git a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
new file mode 100644
index 00000000..5a722db4
--- /dev/null
+++ b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildSlideOverPatch,
+ isSlideOverDraftDirty,
+} from "@/domain/work-orders/utils/slide-over-draft";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+/**
+ * Mirrors the failed-save path: optimistic parent row absorbs the patch, then
+ * rolls back, while draft is compared against a frozen pre-save baseline.
+ */
+function afterFailedOptimisticSave(input: {
+ baseline: WorkOrderTableRow;
+ draft: WorkOrderTableRow;
+ optimisticParent: WorkOrderTableRow;
+ rolledBackParent: WorkOrderTableRow;
+}) {
+ const dirtyAgainstOptimistic = isSlideOverDraftDirty(input.draft, input.optimisticParent);
+ const dirtyAgainstBaseline = isSlideOverDraftDirty(input.draft, input.baseline);
+ const dirtyAgainstRolledBack = isSlideOverDraftDirty(input.draft, input.rolledBackParent);
+ const retryPatch = buildSlideOverPatch(input.draft, input.baseline);
+ return {
+ dirtyAgainstOptimistic,
+ dirtyAgainstBaseline,
+ dirtyAgainstRolledBack,
+ retryPatch,
+ canRetry: dirtyAgainstBaseline && Object.keys(retryPatch).length > 0,
+ };
+}
+
+function row(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 1,
+ woNumber: "1001",
+ site: "SITE-A",
+ type: "PM",
+ dispatcherId: "10",
+ dispatcherName: "Alex",
+ scheduledOn: "2026-07-16",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "2026-07-16",
+ dayLabel: "Thu",
+ location: "Main",
+ locationId: 5,
+ pm: "Plumbing",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "v1",
+ company: "Acme",
+ tech: "Sam",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "09:00",
+ status: "Incomplete",
+ docStatus: "",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "2026-07-20",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "rv1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ isAddOn: false,
+ ...overrides,
+ };
+}
+
+describe("slide-over failed-save baseline contract", () => {
+ it("stays dirty vs frozen baseline after optimistic parent absorbs the edit", () => {
+ const baseline = row({ woNumber: "1001" });
+ const draft = row({ woNumber: "2002" });
+ const optimisticParent = row({ woNumber: "2002" });
+
+ const result = afterFailedOptimisticSave({
+ baseline,
+ draft,
+ optimisticParent,
+ rolledBackParent: baseline,
+ });
+
+ // Bug the review caught: comparing against optimistic parent clears dirty.
+ expect(result.dirtyAgainstOptimistic).toBe(false);
+ expect(result.dirtyAgainstBaseline).toBe(true);
+ expect(result.dirtyAgainstRolledBack).toBe(true);
+ expect(result.canRetry).toBe(true);
+ expect(result.retryPatch).toEqual({ woNumber: "2002" });
+ });
+});
diff --git a/src/test/domain/work-orders/utils/slide-over-save-contract.test.ts b/src/test/domain/work-orders/utils/slide-over-save-contract.test.ts
new file mode 100644
index 00000000..31828684
--- /dev/null
+++ b/src/test/domain/work-orders/utils/slide-over-save-contract.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it, vi } from "vitest";
+
+/**
+ * Mirrors WorkOrderSlideOver commit: exit edit mode only after a successful save
+ * (or when there is nothing to persist).
+ */
+function commitSlideOverEdit(input: {
+ patchKeys: number;
+ onSave: (options: { onSuccess?: () => void }) => void;
+ exitEdit: () => void;
+ clearUnsaved: () => void;
+}) {
+ if (input.patchKeys === 0) {
+ input.exitEdit();
+ input.clearUnsaved();
+ return;
+ }
+
+ input.onSave({
+ onSuccess: () => {
+ input.exitEdit();
+ input.clearUnsaved();
+ },
+ });
+}
+
+describe("slide-over commit save contract", () => {
+ it("exits edit mode immediately when there is no patch", () => {
+ const exitEdit = vi.fn();
+ const clearUnsaved = vi.fn();
+ const onSave = vi.fn();
+
+ commitSlideOverEdit({ patchKeys: 0, onSave, exitEdit, clearUnsaved });
+
+ expect(onSave).not.toHaveBeenCalled();
+ expect(exitEdit).toHaveBeenCalledOnce();
+ expect(clearUnsaved).toHaveBeenCalledOnce();
+ });
+
+ it("keeps edit mode when save fails (onSuccess never runs)", () => {
+ const exitEdit = vi.fn();
+ const clearUnsaved = vi.fn();
+
+ commitSlideOverEdit({
+ patchKeys: 1,
+ onSave: () => {
+ /* rejected — do not call onSuccess */
+ },
+ exitEdit,
+ clearUnsaved,
+ });
+
+ expect(exitEdit).not.toHaveBeenCalled();
+ expect(clearUnsaved).not.toHaveBeenCalled();
+ });
+
+ it("exits edit mode only from onSuccess after a rejected-then-retry path would not", () => {
+ const exitEdit = vi.fn();
+ const clearUnsaved = vi.fn();
+ let success: (() => void) | undefined;
+
+ commitSlideOverEdit({
+ patchKeys: 2,
+ onSave: (options) => {
+ success = options.onSuccess;
+ },
+ exitEdit,
+ clearUnsaved,
+ });
+
+ expect(exitEdit).not.toHaveBeenCalled();
+ success?.();
+ expect(exitEdit).toHaveBeenCalledOnce();
+ expect(clearUnsaved).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
index 706f8ec2..a6c26376 100644
--- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts
+++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
@@ -26,6 +26,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
dispatcherId: "u1",
dispatcherName: "Alice",
scheduledOn: "2026-06-23",
+ completedDate: "",
targetWeek: "",
dayGroup: "06/23",
dayLabel: "TUE · 06/23",
diff --git a/src/test/domain/work-orders/utils/wizard-date-utils.test.ts b/src/test/domain/work-orders/utils/wizard-date-utils.test.ts
index 3ee9c66a..8f63bbbf 100644
--- a/src/test/domain/work-orders/utils/wizard-date-utils.test.ts
+++ b/src/test/domain/work-orders/utils/wizard-date-utils.test.ts
@@ -1,8 +1,10 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import {
fmtDateRange,
fmtShortDate,
+ pad2,
rangeDayCount,
+ todayIso,
} from "@/domain/work-orders/utils/wizard-date-utils";
describe("fmtShortDate", () => {
@@ -29,3 +31,27 @@ describe("rangeDayCount", () => {
expect(rangeDayCount("2026-06-08", "2026-06-08")).toBe(1);
});
});
+
+describe("todayIso", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("uses local calendar day, not UTC ISO date, near midnight", () => {
+ // 02:30 UTC is still the previous local evening in Americas (UTC−N).
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-08-11T02:30:00.000Z"));
+
+ const now = new Date();
+ const expectedLocal = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
+ const utcSlice = now.toISOString().slice(0, 10);
+
+ expect(todayIso()).toBe(expectedLocal);
+ expect(utcSlice).toBe("2026-08-11");
+
+ if (now.getTimezoneOffset() > 0) {
+ expect(todayIso()).toBe("2026-08-10");
+ expect(todayIso()).not.toBe(utcSlice);
+ }
+ });
+});
diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
index 932e74e4..2ae1f827 100644
--- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
+++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
@@ -3,6 +3,7 @@ import {
canDeleteWorkOrderMedia,
isWorkOrderDocumentsReadOnly,
isWorkOrderFileTooLarge,
+ isWorkOrderMediaTypeAllowed,
MAX_WORK_ORDER_MEDIA_BYTES,
} from "@/domain/work-orders/utils/work-order-media-limits";
@@ -23,4 +24,17 @@ describe("work-order-media-limits", () => {
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
});
+
+ it("allows only JPG PNG MP4 MOV by MIME or extension", () => {
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(true);
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.png", type: "image/png" } as File)).toBe(true);
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "video/mp4" } as File)).toBe(true);
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe(
+ true,
+ );
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe(false);
+ expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe(
+ false,
+ );
+ });
});
diff --git a/src/test/domain/work-orders/utils/work-order-patch-queue.test.ts b/src/test/domain/work-orders/utils/work-order-patch-queue.test.ts
new file mode 100644
index 00000000..c088e3c1
--- /dev/null
+++ b/src/test/domain/work-orders/utils/work-order-patch-queue.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+describe("createWorkOrderPatchQueue", () => {
+ it("serializes calls for the same id so the second never starts before the first settles", async () => {
+ const enqueue = createWorkOrderPatchQueue();
+ const events: string[] = [];
+
+ const first = enqueue(1, async () => {
+ events.push("first:start");
+ await delay(20);
+ events.push("first:end");
+ return "first";
+ });
+ const second = enqueue(1, async () => {
+ events.push("second:start");
+ await delay(1);
+ events.push("second:end");
+ return "second";
+ });
+
+ await Promise.all([first, second]);
+
+ expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]);
+ });
+
+ it("does not block work queued for a different id", async () => {
+ const enqueue = createWorkOrderPatchQueue();
+ const events: string[] = [];
+
+ const slow = enqueue(1, async () => {
+ events.push("slow:start");
+ await delay(20);
+ events.push("slow:end");
+ });
+ const fast = enqueue(2, async () => {
+ events.push("fast:start");
+ await delay(1);
+ events.push("fast:end");
+ });
+
+ await Promise.all([slow, fast]);
+
+ expect(events.indexOf("fast:end")).toBeLessThan(events.indexOf("slow:end"));
+ });
+
+ it("still runs the next task after a prior task rejects", async () => {
+ const enqueue = createWorkOrderPatchQueue();
+
+ await expect(
+ enqueue(1, async () => {
+ throw new Error("boom");
+ }),
+ ).rejects.toThrow("boom");
+
+ await expect(enqueue(1, async () => "recovered")).resolves.toBe("recovered");
+ });
+});
From 8d1a3340a8cc3fe8303b4f9e60705e937957b8f4 Mon Sep 17 00:00:00 2001
From: Arthur Bassi
Date: Tue, 18 Aug 2026 09:20:18 -0300
Subject: [PATCH 5/5] fix(work-orders): align Past Due overlay and copy to
Schedule On (SH-185)
Dispatchers were told to change Due Date, which cannot clear Past Due.
Drive the table overlay from Schedule On and match the 422 contract.
---
.../list/table/cells/status-cell.tsx | 6 +--
.../work-orders/utils/table-filter-utils.ts | 8 ++--
.../status-cell-past-due-copy.test.tsx | 38 ++++++++++++++++++
.../utils/table-filter-utils.test.ts | 40 +++++++++----------
4 files changed, 65 insertions(+), 27 deletions(-)
create mode 100644 src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx
index d2e4c78a..c5857f1e 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx
@@ -49,7 +49,7 @@ export function StatusCell({
setAnchorEl(e.currentTarget);
}}
- title="This WO missed its deadline. Update the Due Date to unlock status updates."
+ title="This WO missed its deadline. Update Schedule On to unlock status updates."
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
@@ -83,7 +83,7 @@ export function StatusCell({
color: "var(--status-pending-text)",
}}
>
- This work order is past due. Update the Due Date before the status can be updated.
+ This work order is past due. Update Schedule On before the status can be updated.
- Update Due Date →
+ Update Schedule On →
diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts
index 4a3046d3..6938c74c 100644
--- a/src/domain/work-orders/utils/table-filter-utils.ts
+++ b/src/domain/work-orders/utils/table-filter-utils.ts
@@ -12,18 +12,18 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
- if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
+ if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
return false;
}
const today = new Date().toISOString().slice(0, 10);
- return row.dueDate < today;
+ return row.scheduledOn.slice(0, 10) < today;
}
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
return {
...row,
- // Recompute from dueDate (same date-only rule as BE). Avoid sticky
- // `apiTrue || compute` so a future Due Date clears Past Due immediately.
+ // Recompute from Schedule On (SH-185). Avoid sticky `apiTrue || compute`
+ // so a future Schedule On clears Past Due immediately.
isPastDue: computeIsPastDue(row),
};
}
diff --git a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx
new file mode 100644
index 00000000..961e830c
--- /dev/null
+++ b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx
@@ -0,0 +1,38 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const pastDueRow = {
+ id: "wo-1",
+ status: "Scheduled",
+ dispatcherId: "",
+ dispatcherName: "",
+ company: "",
+ tech: "",
+ completedDate: "",
+ woNumber: "",
+ pm: "",
+ docStatus: "",
+ mediaCount: 0,
+ type: "PM",
+ isPastDue: true,
+ scheduledOn: "2026-06-01",
+ dueDate: "2026-08-01",
+} as WorkOrderTableRow;
+
+describe("StatusCell Past Due copy (SH-185)", () => {
+ it("directs the dispatcher to update Schedule On, not Due Date", () => {
+ render( );
+
+ const trigger = screen.getByRole("button", { name: /past due/i });
+ expect(trigger.getAttribute("title") ?? "").toContain("Schedule On");
+ expect(trigger.getAttribute("title") ?? "").not.toMatch(/due date/i);
+
+ fireEvent.click(trigger);
+
+ expect(screen.getByText(/update schedule on before the status can be updated/i)).toBeTruthy();
+ expect(screen.getByRole("button", { name: /update schedule on/i })).toBeTruthy();
+ expect(screen.queryByText(/due date/i)).toBeNull();
+ });
+});
diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
index e3988d10..ea303e6c 100644
--- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts
+++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
@@ -156,43 +156,43 @@ describe("matchesAdvancedFilters", () => {
});
describe("computeIsPastDue", () => {
- it("is true when dueDate is before today even if scheduledOn is future", () => {
+ it("is true when scheduledOn is before today even if dueDate is future", () => {
expect(
computeIsPastDue(
makeRow({
- dueDate: shiftIso(-1),
- scheduledOn: shiftIso(5),
+ dueDate: shiftIso(5),
+ scheduledOn: shiftIso(-1),
status: "Scheduled",
}),
),
).toBe(true);
});
- it("is false when dueDate is future even if scheduledOn is past", () => {
+ it("is false when scheduledOn is future even if dueDate is past", () => {
expect(
computeIsPastDue(
makeRow({
- dueDate: shiftIso(5),
- scheduledOn: shiftIso(-1),
+ dueDate: shiftIso(-1),
+ scheduledOn: shiftIso(5),
status: "Incomplete",
}),
),
).toBe(false);
});
- it("is false when dueDate is empty", () => {
+ it("is false when scheduledOn is empty", () => {
expect(
computeIsPastDue(
makeRow({
- dueDate: "",
- scheduledOn: shiftIso(-3),
+ dueDate: shiftIso(-3),
+ scheduledOn: "",
status: "Scheduled",
}),
),
).toBe(false);
});
- it("is false for terminal statuses even with past dueDate", () => {
+ it("is false for terminal statuses even with past scheduledOn", () => {
expect(
computeIsPastDue(
makeRow({
@@ -205,19 +205,19 @@ describe("computeIsPastDue", () => {
expect(
computeIsPastDue(
makeRow({
- dueDate: shiftIso(-2),
+ scheduledOn: shiftIso(-2),
status: "Canceled",
}),
),
).toBe(false);
});
- it("is false when dueDate is today", () => {
+ it("is false when scheduledOn is today", () => {
expect(
computeIsPastDue(
makeRow({
- dueDate: todayIso(),
- scheduledOn: shiftIso(-1),
+ dueDate: shiftIso(-1),
+ scheduledOn: todayIso(),
status: "Scheduled",
}),
),
@@ -226,11 +226,11 @@ describe("computeIsPastDue", () => {
});
describe("enrichTableRow", () => {
- it("recomputes isPastDue from dueDate and ignores sticky API true", () => {
+ it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => {
const enriched = enrichTableRow(
makeRow({
- dueDate: shiftIso(5),
- scheduledOn: shiftIso(-1),
+ dueDate: shiftIso(-1),
+ scheduledOn: shiftIso(5),
isPastDue: true,
status: "Scheduled",
}),
@@ -238,11 +238,11 @@ describe("enrichTableRow", () => {
expect(enriched.isPastDue).toBe(false);
});
- it("sets isPastDue true from past dueDate even when API flag is false", () => {
+ it("sets isPastDue true from past scheduledOn even when API flag is false", () => {
const enriched = enrichTableRow(
makeRow({
- dueDate: shiftIso(-1),
- scheduledOn: shiftIso(5),
+ dueDate: shiftIso(5),
+ scheduledOn: shiftIso(-1),
isPastDue: false,
status: "Incomplete",
}),