From 9cc9bc4dfb47fdec65690210c1f1299b13e295b9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 2 Sep 2026 15:11:31 -0300 Subject: [PATCH 01/25] feat(work-orders): align inline lifecycle transitions with AAP Use the prototype status list and gates on the board and wizard. Auto-derive Scheduled or Pending from a concrete schedule date. --- .../list/table/cells/status-cell.tsx | 36 ++-- .../list/table/to-inline-draft-row.ts | 2 + .../list/table/wo-table-inline-row-cells.tsx | 2 + .../wizard/wizard-step-vendor-time.tsx | 36 ++-- .../use-work-order-table-mutations.ts | 25 ++- .../utils/wo-lifecycle-transitions.ts | 169 ++++++++++++++++++ .../status-cell-closability.test.tsx | 41 ++++- .../status-cell-past-due-copy.test.tsx | 10 +- .../status-cell-pending-uplift.test.tsx | 9 +- ...zard-step-vendor-time-closability.test.tsx | 39 ++-- .../use-work-order-table-mutations.test.tsx | 70 ++++++++ .../utils/format-board-audit-action.test.ts | 12 ++ .../utils/wo-lifecycle-transitions.test.ts | 125 +++++++++++++ 13 files changed, 504 insertions(+), 72 deletions(-) create mode 100644 src/domain/work-orders/utils/wo-lifecycle-transitions.ts create mode 100644 src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts 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 d55176bd..a9ee2647 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 @@ -2,16 +2,19 @@ import { useState } from "react"; import { AlertTriangle, Check } from "lucide-react"; import { Popover } from "@mui/material"; import { Text } from "@/components/ui/text"; -import { - ALL_WIZARD_STATUSES, - type WizardWOStatus, -} from "@/domain/work-orders/types/work-order-wizard"; +import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { - formatClosabilityMissingTitle, getClosabilityGaps, tableRowToClosabilityInput, } from "@/domain/work-orders/utils/wo-closability"; +import { + BOARD_INLINE_STATUSES, + PAST_DUE_BADGE_TOOLTIP, + PAST_DUE_POPOVER_BODY, + PAST_DUE_RESCHEDULE_CTA, + getStatusOptionState, +} from "@/domain/work-orders/utils/wo-lifecycle-transitions"; import { HoverChevron } from "./hover-chevron"; import { WoTableStatusLabel } from "./wo-table-status-label"; @@ -24,6 +27,7 @@ type StatusCellProps = { onPastDueAttempt?: () => void; onRequestComplete?: () => void; readOnly?: boolean; + statusOptions?: readonly WizardWOStatus[]; }; function ReadOnlyStatusLabel({ status }: { status: string }) { @@ -64,7 +68,7 @@ function PastDueStatusCell({ onPastDueAttempt?.(); setAnchorEl(e.currentTarget); }} - title="This WO missed its deadline. Update Schedule On to unlock status updates." + title={PAST_DUE_BADGE_TOOLTIP} 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)" }} > @@ -97,7 +101,7 @@ function PastDueStatusCell({ color: "var(--status-pending-text)", }} > - This work order is past due. Update Schedule On before the status can be updated. + {PAST_DUE_POPOVER_BODY} @@ -124,6 +128,7 @@ export function StatusCell({ onPastDueAttempt, onRequestComplete, readOnly = false, + statusOptions = BOARD_INLINE_STATUSES, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); const closabilityGaps = rowClosabilityGaps(wo); @@ -164,20 +169,17 @@ export function StatusCell({ onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }} > - {ALL_WIZARD_STATUSES.map((status) => { - const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0; - const missingTitle = isCompletedBlocked - ? formatClosabilityMissingTitle(closabilityGaps) - : undefined; + {statusOptions.map((status) => { + const option = getStatusOptionState(wo, status, closabilityGaps); return ( setAnchorEl(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} > - {Boolean(allowWeekMode) && ( -
- {(["week", "date"] as const).map((m) => { - const active = cellMode === m; - return ( - - ); - })} -
- )} + {allowWeekMode && cellMode === "week" ? ( + ) : allowRange && cellMode === "range" ? ( + { + onSaveRange?.(start, end); + setAnchorEl(null); + }} + /> ) : ( void; + revealScheduledRow: (id: string | number) => void; +}) { + const { id, date, weekMonday, onNavigateToDay, revealScheduledRow } = args; + if (shouldNotifyCrossWeek(date, weekMonday)) { + rescheduleNotify(date, { + onNavigate: () => { + onNavigateToDay(date); + revealScheduledRow(id); + }, + }); + return; + } + onNavigateToDay(date); + revealScheduledRow(id); +} + type UseWoTableRowHandlersOptions = { tableData: ReturnTypeOfUseWorkOrdersTableData; weekMonday: string; @@ -81,23 +103,31 @@ export function useWoTableRowHandlers({ }, onScheduleDate: (id: string | number, date: string) => { onPatch(id, applySchedulePatch(date, false)); - if (shouldNotifyCrossWeek(date, weekMonday)) { - rescheduleNotify(date, { - onNavigate: () => { - onNavigateToDay(date); - revealScheduledRow(id); - }, - }); - return; - } - onNavigateToDay(date); - revealScheduledRow(id); + revealOrNotifyScheduledDay({ + id, + date, + weekMonday, + onNavigateToDay, + revealScheduledRow, + }); + }, + onScheduleRange: (id: string | number, start: string, end: string) => { + onPatch(id, applyScheduleRangePatch(start, end)); + revealOrNotifyScheduledDay({ + id, + date: start, + weekMonday, + onNavigateToDay, + revealScheduledRow, + }); }, onScheduleWeek: (id: string | number, week: string) => { onPatch(id, { scheduledOn: "", targetWeek: week, scheduleWeekOnly: true, + scheduleEnd: "", + schedMode: "week", dayGroup: "", dayLabel: "", }); 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 cde640c8..ebd484f4 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 @@ -78,7 +78,8 @@ function InlineScheduleOnCell({ weekDefault: boolean; onPatch: (patch: Partial) => void; }) { - const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek); + const hasSchedule = + Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd); return ( onPatch( withAutoScheduleStatus(draft, { scheduledOn: date, scheduleWeekOnly: false, targetWeek: "", + scheduleEnd: "", + schedMode: "date", isAddOnOverride: false, }), ) @@ -103,6 +108,20 @@ function InlineScheduleOnCell({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true, + scheduleEnd: "", + schedMode: "week", + isAddOnOverride: false, + }), + ) + } + onSaveRange={(start, end) => + onPatch( + withAutoScheduleStatus(draft, { + scheduledOn: start, + scheduleEnd: end, + schedMode: "range", + scheduleWeekOnly: false, + targetWeek: "", isAddOnOverride: false, }), ) @@ -115,6 +134,8 @@ function InlineScheduleOnCell({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false, + scheduleEnd: "", + schedMode: undefined, isAddOnOverride: false, }), ) diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx index c6fbde16..8d04a227 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx @@ -129,8 +129,11 @@ export function WoTableRowCells({ highlight={forceScheduleHighlight} allowWeekMode targetWeek={row.targetWeek} + scheduleEnd={row.scheduleEnd} + schedMode={row.schedMode} onSave={(date) => handlers.onScheduleDate(row.id, date)} onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)} + onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)} /> void; onScheduleDate: (id: string | number, date: string) => void; onScheduleWeek: (id: string | number, week: string) => void; + onScheduleRange: (id: string | number, start: string, end: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; onOpenSlideOver: (row: WorkOrderTableRow) => void; onRequestComplete?: (row: WorkOrderTableRow) => void; @@ -66,6 +67,31 @@ type WoTableRowProps = { handlers: WoTableRowHandlers; }; +function guardScheduleHandlers( + row: WorkOrderTableRow, + handlers: WoTableRowHandlers, +): WoTableRowHandlers { + return { + ...handlers, + onPatch: (id, patch, options) => { + if (!canApplyWorkOrderTablePatch(row.status, patch)) return; + handlers.onPatch(id, patch, options); + }, + onScheduleDate: (id, date) => { + if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return; + handlers.onScheduleDate(id, date); + }, + onScheduleWeek: (id, week) => { + if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return; + handlers.onScheduleWeek(id, week); + }, + onScheduleRange: (id, start, end) => { + if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return; + handlers.onScheduleRange(id, start, end); + }, + }; +} + export function WoTableRow({ row, allRows, @@ -88,21 +114,7 @@ export function WoTableRow({ const isCoreLocked = isWorkOrderCoreLocked(row.status); const closedForEdit = isCanceled || isCoreLocked; - const guardedHandlers: WoTableRowHandlers = { - ...handlers, - onPatch: (id, patch, options) => { - if (!canApplyWorkOrderTablePatch(row.status, patch)) return; - handlers.onPatch(id, patch, options); - }, - onScheduleDate: (id, date) => { - if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return; - handlers.onScheduleDate(id, date); - }, - onScheduleWeek: (id, week) => { - if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return; - handlers.onScheduleWeek(id, week); - }, - }; + const guardedHandlers = guardScheduleHandlers(row, handlers); const patchRow = (patch: Partial) => { if (!canApplyWorkOrderTablePatch(row.status, patch)) return; diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts index ae48b4b5..1f89ce5a 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts @@ -9,6 +9,7 @@ type UseWizardDateFieldStateArgs = { allowWeekMode?: boolean; targetWeek: string; schedMode?: WizardSchedMode; + defaultMode?: "week" | "date"; }; function resolveInitialMode({ @@ -17,9 +18,10 @@ function resolveInitialMode({ allowWeekMode, value, targetWeek, + defaultMode, }: Pick< UseWizardDateFieldStateArgs, - "allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek" + "allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek" | "defaultMode" >): WizardSchedMode { if (allowRange && schedMode === "range") { return "range"; @@ -27,6 +29,9 @@ function resolveInitialMode({ if (allowWeekMode && !value && targetWeek) { return "week"; } + if (allowWeekMode && defaultMode) { + return defaultMode; + } return "date"; } @@ -39,6 +44,7 @@ export function useWizardDateFieldState({ allowWeekMode, targetWeek, schedMode, + defaultMode, }: UseWizardDateFieldStateArgs) { const initialMode = resolveInitialMode({ allowRange, @@ -46,6 +52,7 @@ export function useWizardDateFieldState({ allowWeekMode, value, targetWeek, + defaultMode, }); const [cellMode, setCellMode] = useState(initialMode); diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index c5714dc6..66338697 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -43,6 +43,9 @@ const LOCAL_ONLY_KEYS = new Set([ /** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */ "isAddOn", "primaryDispatchStatus", + /** Client-only Schedule On range overlay — API persists range start as scheduledDate. */ + "scheduleEnd", + "schedMode", ]); export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean { 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 fa7b7a63..92040fa2 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -1,5 +1,5 @@ import type { WorkOrderAdditionalContact } from "./work-order-additional-contact"; -import type { WizardWOStatus, WOType } from "./work-order-wizard"; +import type { WizardSchedMode, WizardWOStatus, WOType } from "./work-order-wizard"; import type { WorkOrderSeverity } from "./work-order-severity"; import type { UpliftSummary } from "./work-order-uplift"; @@ -65,6 +65,10 @@ export interface WorkOrderTableRow { originalDate: string; isPastDue: boolean; scheduleWeekOnly: boolean; + /** Optimistic range end; API persists only `scheduledOn` (range start). */ + scheduleEnd?: string; + /** Last Schedule On mode used in this session. */ + schedMode?: WizardSchedMode; mediaCount: number; title: string; priority: string; @@ -105,6 +109,8 @@ export interface WorkOrderTableDraft { additionalContacts: WorkOrderAdditionalContact[]; scheduleWeekOnly: boolean; targetWeek: string; + scheduleEnd?: string; + schedMode?: WizardSchedMode; /** Manual Add-On override when schedule is empty at inline create. */ isAddOnOverride: boolean; } @@ -137,6 +143,8 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = { additionalContacts: [], scheduleWeekOnly: false, targetWeek: "", + scheduleEnd: "", + schedMode: undefined, isAddOnOverride: false, }; 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 f77d44a9..e2ffbd91 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 @@ -220,6 +220,8 @@ export function applySchedulePatch( scheduledOn: "", targetWeek: scheduledOn, scheduleWeekOnly: true, + scheduleEnd: "", + schedMode: "week", ...deriveDayFields(""), }; } @@ -229,6 +231,8 @@ export function applySchedulePatch( scheduledOn: "", scheduleWeekOnly: false, targetWeek: "", + scheduleEnd: "", + schedMode: undefined, isAddOn: false, ...deriveDayFields(""), }; @@ -237,6 +241,17 @@ export function applySchedulePatch( scheduledOn: trimmed, scheduleWeekOnly: false, targetWeek: "", + scheduleEnd: "", + schedMode: "date", ...deriveDayFields(trimmed), }; } + +/** Range lands on the first day; end/mode stay optimistic (no board API field). */ +export function applyScheduleRangePatch(start: string, end: string): WorkOrderTablePatch { + return { + ...applySchedulePatch(start, false), + scheduleEnd: end, + schedMode: "range", + }; +} 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 index 674a9b63..593d4631 100644 --- 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 @@ -93,6 +93,31 @@ describe("buildInlineDraftCreateInput", () => { expect(payload).not.toHaveProperty("assignTo"); }); + it("maps range mode so create persists the start as scheduledDate", () => { + const draft = { + ...EMPTY_TABLE_DRAFT, + site: "BK5", + locationId: "9", + woNumber: "25007", + pocName: "Jane", + pocPhone: "555-0100", + scheduledOn: "2026-06-08", + scheduleEnd: "2026-06-12", + schedMode: "range" as const, + scheduleWeekOnly: false, + }; + + const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]); + const payload = mapWizardDraftToBoardCreatePayload(input); + + expect(input.schedMode).toBe("range"); + expect(input.scheduleEnd).toBe("2026-06-12"); + expect(payload.scheduledDate).toBe("2026-06-08"); + expect(payload.scheduleWeekOnly).toBeUndefined(); + expect(payload.targetWeek).toBeUndefined(); + expect(payload).not.toHaveProperty("scheduleEnd"); + }); + it("promotes Pending to Scheduled when create has a concrete date", () => { const draft = { ...EMPTY_TABLE_DRAFT, diff --git a/src/test/app/(protected)/workorders/date-cell-schedule-range.test.tsx b/src/test/app/(protected)/workorders/date-cell-schedule-range.test.tsx new file mode 100644 index 00000000..698918c4 --- /dev/null +++ b/src/test/app/(protected)/workorders/date-cell-schedule-range.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell"; +import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell"; +import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +function currentMonthIso(day: number): string { + const [y, m] = todayIso().split("-").map(Number); + return `${y}-${pad2(m)}-${pad2(day)}`; +} + +async function openRangeTab(triggerName: RegExp | string) { + fireEvent.click(screen.getByRole("button", { name: triggerName })); + const rangeTab = await screen.findByRole("button", { name: "Range" }); + fireEvent.click(rangeTab); +} + +describe("DateCell — Schedule On range", () => { + it("shows Week, Date, and Range when onSaveRange is provided", async () => { + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "—" })); + + expect(await screen.findByRole("button", { name: "Week" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Date" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Range" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Specific date" })).not.toBeInTheDocument(); + }, 10_000); + + it("keeps Week / Specific date and hides Range without onSaveRange", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: "—" })); + + expect(await screen.findByRole("button", { name: "Week" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Specific date" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Range" })).not.toBeInTheDocument(); + }); + + it("submits start and end via Done, including inverted clicks", async () => { + const onSaveRange = vi.fn(); + renderWithProviders( + , + ); + + await openRangeTab("—"); + fireEvent.click(screen.getByRole("button", { name: "20" })); + fireEvent.click(screen.getByRole("button", { name: "5" })); + fireEvent.click(screen.getByRole("button", { name: /^done$/i })); + + expect(onSaveRange).toHaveBeenCalledWith(currentMonthIso(5), currentMonthIso(20)); + }); + + it("does not submit until both ends are picked", async () => { + const onSaveRange = vi.fn(); + renderWithProviders( + , + ); + + await openRangeTab("—"); + fireEvent.click(screen.getByRole("button", { name: "5" })); + + expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled(); + expect(onSaveRange).not.toHaveBeenCalled(); + }); + + it("does not persist when switching tabs without confirming", async () => { + const onSave = vi.fn(); + const onSaveRange = vi.fn(); + renderWithProviders( + , + ); + + await openRangeTab("—"); + fireEvent.click(screen.getByRole("button", { name: "5" })); + fireEvent.click(screen.getByRole("button", { name: "Date" })); + + expect(onSave).not.toHaveBeenCalled(); + expect(onSaveRange).not.toHaveBeenCalled(); + }); +}); + +describe("DueDateCell — no Schedule On range tabs", () => { + it("does not offer Week / Date / Range on a PM due date", () => { + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: /07-15/ })); + + expect(screen.queryByRole("button", { name: "Week" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Range" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Specific date" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-schedule-range.test.tsx b/src/test/app/(protected)/workorders/slide-over-schedule-range.test.tsx new file mode 100644 index 00000000..2fb72672 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-schedule-range.test.tsx @@ -0,0 +1,124 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({ + SiteCell: () => null, +})); +vi.mock( + "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell", + () => ({ + EditableWONumberCell: () => null, + }), +); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({ + TypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ + AssignedToCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); + +function currentMonthIso(day: number): string { + const [y, m] = todayIso().split("-").map(Number); + return `${y}-${pad2(m)}-${pad2(day)}`; +} + +function baseRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 42, + woNumber: "WO-42", + site: "SEA1", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Dispatcher", + scheduledOn: "", + completedDate: "", + targetWeek: "", + dayGroup: "", + dayLabel: "", + location: "Seattle", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + company: "Vendor Co", + tech: "Tech", + techPhone: "", + vendorId: "v1", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Incomplete", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + additionalContacts: [], + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + isAddOn: false, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + ...overrides, + }; +} + +describe("SlideOverInfoTabEditView — Schedule On range", () => { + it("patches range start/end and schedMode from the DateCell", async () => { + const onDraftChange = vi.fn(); + renderWithProviders( + , + ); + + const scheduleRow = screen.getByText("Schedule On").closest("div"); + fireEvent.click(scheduleRow!.querySelector("button")!); + fireEvent.click(await screen.findByRole("button", { name: "Range" })); + fireEvent.click(screen.getByRole("button", { name: "5" })); + fireEvent.click(screen.getByRole("button", { name: "20" })); + fireEvent.click(screen.getByRole("button", { name: /^done$/i })); + + expect(onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ + scheduledOn: currentMonthIso(5), + scheduleEnd: currentMonthIso(20), + schedMode: "range", + scheduleWeekOnly: false, + targetWeek: "", + }), + ); + }, 10_000); +}); diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts index 0f0ed39a..c4cc0867 100644 --- a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts +++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts @@ -1,7 +1,10 @@ import { renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers"; -import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { + applySchedulePatch, + applyScheduleRangePatch, +} from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; const { rescheduleNotify, weekMoveNotify } = vi.hoisted(() => ({ rescheduleNotify: vi.fn(), @@ -25,6 +28,11 @@ function buildTableData(revealScheduledRow = vi.fn()) { } describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { + beforeEach(() => { + rescheduleNotify.mockClear(); + weekMoveNotify.mockClear(); + }); + it("selects the scheduled weekday in the same week so the row stays visible", () => { const onNavigateToDay = vi.fn(); const onPatch = vi.fn(); @@ -80,6 +88,34 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { expect(revealScheduledRow).toHaveBeenCalledWith("wo-1"); }); + it("lands a range on the start day and reveals the row", () => { + const onNavigateToDay = vi.fn(); + const onPatch = vi.fn(); + const revealScheduledRow = vi.fn(); + + const { result } = renderHook(() => + useWoTableRowHandlers({ + tableData: buildTableData(revealScheduledRow), + weekMonday: "2026-05-18", + onNavigateToDay, + onOpenSlideOver: vi.fn(), + onOpenMedia: vi.fn(), + onPatch, + onScheduleHighlight: vi.fn(), + }), + ); + + result.current.onScheduleRange("wo-1", "2026-05-21", "2026-05-23"); + + expect(onPatch).toHaveBeenCalledWith( + "wo-1", + applyScheduleRangePatch("2026-05-21", "2026-05-23"), + ); + expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21"); + expect(revealScheduledRow).toHaveBeenCalledWith("wo-1"); + expect(rescheduleNotify).not.toHaveBeenCalled(); + }); + it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => { const onNavigateToDay = vi.fn(); const onOpenAdvancedFilters = vi.fn(); diff --git a/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx b/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx new file mode 100644 index 00000000..a276e515 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx @@ -0,0 +1,58 @@ +import { createRef } from "react"; +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableInlineRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells"; +import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row"; +import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +function currentMonthIso(day: number): string { + const [y, m] = todayIso().split("-").map(Number); + return `${y}-${pad2(m)}-${pad2(day)}`; +} + +describe("WoTableInlineRowCells — Schedule On range", () => { + it("patches schedMode range with start and end", async () => { + const onPatch = vi.fn(); + renderWithProviders( + + + + + + +
, + ); + + const scheduleButtons = screen.getAllByRole("button", { name: "—" }); + fireEvent.click(scheduleButtons[0]); + fireEvent.click(await screen.findByRole("button", { name: "Range" })); + fireEvent.click(screen.getByRole("button", { name: "5" })); + fireEvent.click(screen.getByRole("button", { name: "20" })); + fireEvent.click(screen.getByRole("button", { name: /^done$/i })); + + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ + scheduledOn: currentMonthIso(5), + scheduleEnd: currentMonthIso(20), + schedMode: "range", + scheduleWeekOnly: false, + targetWeek: "", + }), + ); + }, 10_000); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx index 2c02e58c..3bbed85f 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx @@ -65,6 +65,7 @@ const handlers: WoTableRowHandlers = { onPatch: vi.fn(), onScheduleDate: vi.fn(), onScheduleWeek: vi.fn(), + onScheduleRange: vi.fn(), onOpenSlideOver: vi.fn(), onOpenCompDoc: vi.fn(), onOpenUplifts: vi.fn(), diff --git a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx index 836eb62b..868d7412 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx @@ -65,6 +65,7 @@ function handlers(overrides: Partial = {}): WoTableRowHandle onPatch: vi.fn(), onScheduleDate: vi.fn(), onScheduleWeek: vi.fn(), + onScheduleRange: vi.fn(), onOpenSlideOver: vi.fn(), onReorder: vi.fn(), onDragStart: vi.fn(), 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 afa1a42b..645154c8 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 @@ -64,6 +64,7 @@ const handlers: WoTableRowHandlers = { onPatch: vi.fn(), onScheduleDate: vi.fn(), onScheduleWeek: vi.fn(), + onScheduleRange: vi.fn(), onOpenSlideOver: vi.fn(), onOpenCompDoc: vi.fn(), onOpenUplifts: vi.fn(), diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index 0cb2c792..0d973632 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -30,6 +30,10 @@ describe("isLocalOnlyTablePatch", () => { it("treats primaryDispatchStatus overlay as local-only", () => { expect(isLocalOnlyTablePatch({ primaryDispatchStatus: "" })).toBe(true); }); + + it("treats scheduleEnd and schedMode as local-only overlays", () => { + expect(isLocalOnlyTablePatch({ scheduleEnd: "2026-06-12", schedMode: "range" })).toBe(true); + }); }); describe("expandTablePatchToBoardFieldOps", () => { @@ -123,6 +127,21 @@ describe("expandTablePatchToBoardFieldOps", () => { ]); }); + it("does not emit scheduleEnd or schedMode as board fields", () => { + const ops = expandTablePatchToBoardFieldOps({ + scheduleWeekOnly: false, + scheduledOn: "2026-06-08", + targetWeek: "", + scheduleEnd: "2026-06-12", + schedMode: "range", + }); + expect(ops).toEqual([ + { field: "scheduleWeekOnly", value: "false" }, + { field: "scheduledDate", value: "2026-06-08" }, + { field: "targetWeek", value: "" }, + ]); + }); + it("emits additionalContacts as serialized JSON for board PATCH", () => { expect( expandTablePatchToBoardFieldOps({ 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 ed614089..514f8991 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 @@ -26,6 +26,7 @@ vi.mock("react-toastify", () => ({ import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; import { applySchedulePatch, + applyScheduleRangePatch, useWorkOrderTableMutations, } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import { queryKeys } from "@/infra/query-key/query-key"; @@ -275,6 +276,16 @@ describe("applySchedulePatch isAddOn optimistic clear", () => { expect(applySchedulePatch("2026-07-15", false).isAddOn).toBeUndefined(); expect(applySchedulePatch("2026-07-13", true).isAddOn).toBeUndefined(); }); + + it("keeps range end/mode optimistic while landing on the start day", () => { + expect(applyScheduleRangePatch("2026-06-08", "2026-06-12")).toMatchObject({ + scheduledOn: "2026-06-08", + scheduleEnd: "2026-06-12", + schedMode: "range", + scheduleWeekOnly: false, + targetWeek: "", + }); + }); }); describe("useWorkOrderTableMutations schedule isAddOn from BE", () => { From ccdad96170cd48868cfc1f77499239d616a96740 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 7 Sep 2026 11:39:12 -0300 Subject: [PATCH 08/25] fix(work-orders): persist type via PATCH and lock completed rows --- .../list/table/cells/assigned-to-cell.tsx | 6 +- .../list/table/cells/type-cell.tsx | 28 +++++--- .../list/table/save-existing-row-type.ts | 25 +++++-- .../list/table/wo-table-row-cells.tsx | 4 +- .../workorders/save-existing-row-type.test.ts | 32 ++++++--- .../(protected)/workorders/type-cell.test.tsx | 17 +++++ .../wo-table-row-cells-type-toast.test.tsx | 69 +++++++++++++++++++ .../wo-table-row-completed-lock.test.tsx | 52 ++++++++++++++ 8 files changed, 205 insertions(+), 28 deletions(-) create mode 100644 src/test/app/(protected)/workorders/wo-table-row-cells-type-toast.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx index cc4cf518..bd0db367 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -28,7 +28,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig e.stopPropagation(); setAnchorEl(e.currentTarget); }} - className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80" + className="group/cell flex w-full min-w-0 items-center gap-1.5 rounded transition-opacity hover:opacity-80" style={ highlight ? { @@ -43,13 +43,13 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig {selected ? ( ) : ( - + - Select dispatcher + Select dispatcher )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx index 4cf14290..949722d6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx @@ -18,14 +18,32 @@ type TypeCellProps = { value: WOType | ""; severity?: WorkOrderSeverity | null; q?: string; + viewOnly?: boolean; onSave: (type: WOType, severity: WorkOrderSeverity | null) => void; }; -export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) { +function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) { + if (value) { + return ; + } + return Select type of WO; +} + +export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [modalSeed, setModalSeed] = useState(value); const [resetSeverity, setResetSeverity] = useState(false); + const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null); + + if (viewOnly) { + return ( +
+ + {Boolean(showSeverityBadge && severity != null) && } +
+ ); + } function pick(t: WOType) { setAnchorEl(null); @@ -43,8 +61,6 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) { onSave(t, needsSeverity ? severity : null); } - const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null); - return ( <> + ), +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({ + DueDateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ + AssignedToCell: () => null, +})); + +const row = { id: 7, woNumber: "00000024965", severity: 3 } as WorkOrderTableRow; + +describe("WoTableRowCells type persist", () => { + it("forwards type save through patch onSuccess instead of onPatchRow", () => { + const onPatch = vi.fn(); + const onPatchRow = vi.fn(); + const handlers = { onPatch } as unknown as WoTableRowHandlers; + + renderWithProviders( + + + + + + +
, + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: "persist-type" })); + + expect(onPatchRow).not.toHaveBeenCalled(); + expect(onPatch).toHaveBeenCalledWith( + 7, + { type: "Emergency", severity: 1 }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx index 2c02e58c..7ced9b99 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx @@ -133,4 +133,56 @@ describe("WoTableRow completed selective lock", () => { expect(screen.queryByRole("button", { name: /in progress/i })).not.toBeInTheDocument(); expect(handlers.onPatch).not.toHaveBeenCalled(); }); + + it("does not expose the type picker on a Completed row", () => { + const row = makeRow(); + renderWithProviders( + + + + +
, + { withAuth: false }, + ); + + expect(screen.queryByRole("button", { name: "Type of WO, PM" })).not.toBeInTheDocument(); + expect(screen.getByText("PM")).toBeInTheDocument(); + }); + + it("does not expose the type picker on a Canceled row", () => { + const row = makeRow({ status: "Canceled" }); + renderWithProviders( + + + + +
, + { withAuth: false }, + ); + + expect(screen.queryByRole("button", { name: "Type of WO, PM" })).not.toBeInTheDocument(); + expect(screen.getByText("PM")).toBeInTheDocument(); + }); }); From 74e5c58e5decc80d842607c042cbec22f3bfb23c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 7 Sep 2026 15:19:39 -0300 Subject: [PATCH 09/25] fix(work-orders): restore completion media uploads over HTTP/1.1 Use XHR for media progress so Ky does not stream the body on HTTP/1.1 (Vite proxy), and keep completion vs extra tiles from mixing after upload. --- src/api/api.ts | 16 +- src/api/upload-form-with-progress.ts | 103 ++++++++++ .../detail/use-slide-over-media-state.ts | 72 +++++-- .../_components/media/media-upload-modal.tsx | 22 +-- .../media/media-uploader-dropzone.tsx | 6 +- .../media/media-uploader-grid-item-meta.tsx | 8 +- .../_components/media/media-uploader.tsx | 7 +- .../api/work-order-board-documents-api.ts | 37 ++-- .../mappers/work-order-board-detail-mapper.ts | 3 +- .../work-orders/utils/display-file-name.ts | 10 + .../utils/partition-slide-over-media.ts | 44 +++++ .../utils/work-order-media-limits.ts | 10 +- src/lib/auth/expire-session.ts | 12 ++ .../api/upload-form-with-progress.test.ts | 184 ++++++++++++++++++ .../media-upload-modal-api.test.tsx | 34 +++- .../use-slide-over-media-state.test.tsx | 81 +++++++- ...ork-orders-board-core-affordances.test.tsx | 24 +++ .../work-orders/api/work-orders-api.test.ts | 44 +++-- .../work-order-board-detail-mapper.test.ts | 14 ++ .../work-order-table-row-mapper.test.ts | 19 ++ .../utils/display-file-name.test.ts | 24 +++ .../utils/partition-slide-over-media.test.ts | 79 ++++++++ .../utils/work-order-media-limits.test.ts | 1 + 23 files changed, 757 insertions(+), 97 deletions(-) create mode 100644 src/api/upload-form-with-progress.ts create mode 100644 src/domain/work-orders/utils/display-file-name.ts create mode 100644 src/domain/work-orders/utils/partition-slide-over-media.ts create mode 100644 src/lib/auth/expire-session.ts create mode 100644 src/test/api/upload-form-with-progress.test.ts create mode 100644 src/test/domain/work-orders/utils/display-file-name.test.ts create mode 100644 src/test/domain/work-orders/utils/partition-slide-over-media.test.ts diff --git a/src/api/api.ts b/src/api/api.ts index 51897b61..3cb8a7f0 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -3,18 +3,8 @@ import { env } from "@/lib/env"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { API_PATHS } from "@/api/api-paths"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; -import queryClient from "@/lib/query/query-client"; -import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast"; -import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; - -function clearSessionAndRedirectToLogin(): void { - clearAuth(); - stopQueryBroadcast(queryClient); - - if (typeof window !== "undefined" && window.location.pathname !== "/login") { - window.location.replace("/login"); - } -} +import { getAccessToken } from "@/lib/auth/auth-storage"; +import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); @@ -51,7 +41,7 @@ export const api: KyInstance = ky.create({ return response; } - clearSessionAndRedirectToLogin(); + expireSessionAndRedirectToLogin(); return response; }, ], diff --git a/src/api/upload-form-with-progress.ts b/src/api/upload-form-with-progress.ts new file mode 100644 index 00000000..90a2a254 --- /dev/null +++ b/src/api/upload-form-with-progress.ts @@ -0,0 +1,103 @@ +import { getAccessToken } from "@/lib/auth/auth-storage"; +import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; +import { env } from "@/lib/env"; +import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url"; + +export interface UploadFormResult { + ok: boolean; + status: number; + data: unknown; +} + +export interface UploadFormOptions { + signal?: AbortSignal; + onProgress?: (percent: number) => void; +} + +function parseJsonBody(text: string): unknown { + if (!text.trim()) { + return undefined; + } + + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function createAbortError(): DOMException { + return new DOMException("The upload was aborted.", "AbortError"); +} + +/** + * hazard: Ky's `onUploadProgress` turns the body into a ReadableStream, and browsers only accept + * streamed request bodies over HTTP/2 — on HTTP/1.1 (Vite proxy, plain-HTTP hosts) the request dies + * as a network error before reaching the API. XHR reports progress on every transport. + */ +export function uploadFormWithProgress( + path: string, + body: FormData, + options?: UploadFormOptions, +): Promise { + return new Promise((resolve, reject) => { + if (options?.signal?.aborted) { + reject(createAbortError()); + return; + } + + const request = new XMLHttpRequest(); + request.open("POST", `${resolveApiBaseUrl(env.apiUrl)}/${path.replace(/^\/+/, "")}`); + request.setRequestHeader("Accept", "application/json"); + + const token = getAccessToken(); + if (token) { + request.setRequestHeader("Authorization", token); + } + + const abortRequest = () => { + request.abort(); + }; + options?.signal?.addEventListener("abort", abortRequest); + + const detachSignal = () => { + options?.signal?.removeEventListener("abort", abortRequest); + }; + + const { onProgress } = options ?? {}; + if (onProgress) { + request.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress(Math.round((event.loaded / event.total) * 100)); + } + }); + } + + request.addEventListener("load", () => { + detachSignal(); + const { status } = request; + + if (status === 401) { + expireSessionAndRedirectToLogin(); + } + + resolve({ + ok: status >= 200 && status < 300, + status, + data: parseJsonBody(request.responseText), + }); + }); + + request.addEventListener("error", () => { + detachSignal(); + reject(new Error("Upload failed. Check your connection and try again.")); + }); + + request.addEventListener("abort", () => { + detachSignal(); + reject(createAbortError()); + }); + + request.send(body); + }); +} diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 94b60262..fe011400 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { toast } from "react-toastify"; import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session"; @@ -9,9 +9,12 @@ import type { useDeleteWorkOrderMedia, useUpdateWorkOrderMediaCategory, useUploadWorkOrderMedia, + UploadWorkOrderMediaInput, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; - -const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]); +import { + isCompletionSurfaceMedia, + isExtrasSurfaceMedia, +} from "@/domain/work-orders/utils/partition-slide-over-media"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; @@ -20,6 +23,20 @@ type UseSlideOverMediaStateArgs = { categorizeMediaMutation: ReturnType; }; +function addOwnedId(previous: Set, id: string | undefined): Set { + if (!id) return previous; + const next = new Set(previous); + next.add(id); + return next; +} + +function dropOwnedId(previous: Set, id: string): Set { + if (!previous.has(id)) return previous; + const next = new Set(previous); + next.delete(id); + return next; +} + export function useSlideOverMediaState({ detailForRow, uploadMediaMutation, @@ -27,28 +44,38 @@ export function useSlideOverMediaState({ categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { const [mediaBatchPending, setMediaBatchPending] = useState(false); + const [completionOwnedIds, setCompletionOwnedIds] = useState>(() => new Set()); + const [extraOwnedIds, setExtraOwnedIds] = useState>(() => new Set()); + + const persistCompletionUpload = useCallback( + async (input: UploadWorkOrderMediaInput) => { + const item = await uploadMediaMutation.mutateAsync(input); + setCompletionOwnedIds((previous) => addOwnedId(previous, item.id)); + setExtraOwnedIds((previous) => dropOwnedId(previous, item.id)); + return item; + }, + [uploadMediaMutation], + ); + const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = - useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input)); + useMediaUploadSession(detailForRow, persistCompletionUpload); + + const ownership = useMemo( + () => ({ completionOwnedIds, extraOwnedIds }), + [completionOwnedIds, extraOwnedIds], + ); const completionMedia = useMemo( - () => - uploads.filter( - (item) => - COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") || - item.uploadStatus === "uploading" || - item.uploadStatus === "failed", - ), - [uploads], + () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)), + [uploads, ownership], ); const extrasUploads = useMemo( () => - boardMediaItemsToMediaFiles( - (detailForRow?.media ?? []).filter( - (item) => item.category === "Extra" || item.category === "Aveta", - ), + boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) => + isExtrasSurfaceMedia(item, ownership), ), - [detailForRow?.media], + [detailForRow?.media, ownership], ); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); @@ -68,7 +95,9 @@ export function useSlideOverMediaState({ try { for (const file of files) { // Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH. - await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + setExtraOwnedIds((previous) => addOwnedId(previous, item.id)); + setCompletionOwnedIds((previous) => dropOwnedId(previous, item.id)); } } finally { setMediaBatchPending(false); @@ -81,6 +110,13 @@ export function useSlideOverMediaState({ toast.error("Missing work order version. Refresh and try again."); return; } + if (category === "Extra") { + setExtraOwnedIds((previous) => addOwnedId(previous, mediaId)); + setCompletionOwnedIds((previous) => dropOwnedId(previous, mediaId)); + } else { + setExtraOwnedIds((previous) => dropOwnedId(previous, mediaId)); + setCompletionOwnedIds((previous) => dropOwnedId(previous, mediaId)); + } categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion }); }; 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 ba84fa1c..e3aa23ee 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -12,7 +12,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta 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"; @@ -35,14 +34,12 @@ function resolveWorkOrderVersion( 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 pending = uploadMutation.isPending || deleteMutation.isPending; const handleClose = guardDialogClose(pending, () => onOpenChange(false)); const requireVersion = (): string | null => { @@ -69,14 +66,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr > e.stopPropagation()}>
- - + + Photos & Videos - #{row.woNumber} + + #{row.woNumber} +
- Upload photos and videos for this work order, then assign Before, After, or Extra. + Upload photos and videos for this work order.
@@ -101,11 +102,6 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr 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} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx index e6b3aa19..439b4693 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx @@ -8,12 +8,14 @@ import { type MediaUploaderDropzoneProps = { inputRef: RefObject; pending: boolean; + categorizeAfterUpload?: boolean; onFilesSelected: (files: FileList | null) => void; }; export function MediaUploaderDropzone({ inputRef, pending, + categorizeAfterUpload = false, onFilesSelected, }: MediaUploaderDropzoneProps) { return ( @@ -39,8 +41,8 @@ export function MediaUploaderDropzone({ {pending ? "Uploading…" : "Drag files here or click to browse"} - Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after - upload + Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file + {categorizeAfterUpload ? " · categorize after upload" : ""} @@ -34,7 +35,7 @@ export function MediaUploaderGridItemMeta({ {file.errorMessage || "Upload failed"}
)} - {needsCategory ? ( + {needsCategory && ( - ) : ( + )} + {Boolean(!needsCategory && managesPurpose) && (
{file.purpose || (file.isLegacy ? "Legacy" : "")}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index 69f4c364..aed81332 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -91,7 +91,12 @@ export function MediaUploader({ return (
{!readOnly && ( - + )} { } } -function throwDocumentHttpError(response: Response, data: unknown): never { +function throwDocumentHttpError(status: number, data: unknown): never { const record = typeof data === "object" && data !== null ? (data as Record) : {}; - if (response.status === 409) { + if (status === 409) { throw new BoardPatchConflictError( typeof record.message === "string" ? record.message @@ -45,14 +46,14 @@ function throwDocumentHttpError(response: Response, data: unknown): never { ); } - if (response.status === 422) { + if (status === 422) { throw new BoardPatchValidationError( typeof record.code === "string" ? record.code : "ValidationError", typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data), ); } - throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status); + throw new ApiError(mapHttpStatusToMessage(status, data), status); } export const workOrderBoardDocumentsApi = { @@ -94,7 +95,7 @@ export const workOrderBoardDocumentsApi = { return mapBoardDetailComment(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, /** @deprecated Prefer createComment — modern board endpoint. */ @@ -119,21 +120,15 @@ export const workOrderBoardDocumentsApi = { 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) { + const { ok, status, data } = await uploadFormWithProgress( + API_PATHS.workOrder.media(workOrderId), + formData, + { signal: options?.signal, onProgress: options?.onProgress }, + ); + if (ok) { return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(status, data); }, updateMediaCategory: async ( @@ -160,7 +155,7 @@ export const workOrderBoardDocumentsApi = { if (response.ok) { return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, deleteMedia: async ( @@ -196,7 +191,7 @@ export const workOrderBoardDocumentsApi = { if (response.ok) { return; } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, uploadCompletionDoc: async ( @@ -224,6 +219,6 @@ export const workOrderBoardDocumentsApi = { if (response.ok) { return mapCompletion(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, }; 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 950f8fd2..e2ae7411 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 @@ -12,6 +12,7 @@ import type { WorkOrderComment, } from "@/domain/work-orders/types/work-order-detail"; import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper"; +import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name"; import type { FileCategory, MediaCategory, @@ -220,7 +221,7 @@ export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem { id: String(id ?? (readString(item, "id") || cryptoRandomId())), category: mapMediaCategory(item.category ?? item.Category), url: readString(item, "url", "Url"), - fileName: readString(item, "fileName", "FileName") || "file", + fileName: toDisplayFileName(readString(item, "fileName", "FileName") || "file"), uploadedAt: readString(item, "uploadedAt", "UploadedAt"), isLegacy: isLegacyFlag || (id != null && id <= 0), }; diff --git a/src/domain/work-orders/utils/display-file-name.ts b/src/domain/work-orders/utils/display-file-name.ts new file mode 100644 index 00000000..65659c24 --- /dev/null +++ b/src/domain/work-orders/utils/display-file-name.ts @@ -0,0 +1,10 @@ +/** + * why: uploads are stored as `{guid}_{original name}`, so the raw storage name shown in media + * tiles reads as an id. The original name (and its extension) survive after the prefix. + */ +const STORED_NAME_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i; + +export function toDisplayFileName(fileName: string): string { + const withoutPrefix = fileName.replace(STORED_NAME_PREFIX, "").trim(); + return withoutPrefix || fileName; +} diff --git a/src/domain/work-orders/utils/partition-slide-over-media.ts b/src/domain/work-orders/utils/partition-slide-over-media.ts new file mode 100644 index 00000000..5de1e4ad --- /dev/null +++ b/src/domain/work-orders/utils/partition-slide-over-media.ts @@ -0,0 +1,44 @@ +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; + +const COMPLETION_PURPOSES = new Set(["Before", "After", "Completion"]); + +export type MediaSurfaceOwnership = { + completionOwnedIds: ReadonlySet; + extraOwnedIds: ReadonlySet; +}; + +const EMPTY_OWNERSHIP: MediaSurfaceOwnership = { + completionOwnedIds: new Set(), + extraOwnedIds: new Set(), +}; + +function isPhotoOrVideo(item: MediaFile): boolean { + return item.fileType === "image" || item.fileType === "video"; +} + +function isInFlight(item: MediaFile): boolean { + return item.uploadStatus === "uploading" || item.uploadStatus === "failed"; +} + +/** Extra photo/video from Completion Doc POST (no category) until Extra is chosen. */ +export function isCompletionSurfaceMedia( + item: MediaFile, + ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, +): boolean { + if (isInFlight(item)) return true; + if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true; + if (ownership.completionOwnedIds.has(item.id)) return true; + if (ownership.extraOwnedIds.has(item.id)) return false; + return item.purpose === "Extra" && isPhotoOrVideo(item); +} + +export function isExtrasSurfaceMedia( + item: MediaFile, + ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, +): boolean { + if (item.purpose === "Aveta") return true; + if (item.purpose !== "Extra") return false; + if (ownership.completionOwnedIds.has(item.id)) return false; + if (ownership.extraOwnedIds.has(item.id)) return true; + return !isPhotoOrVideo(item); +} 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 58cc358a..d93fb7cb 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,7 +1,13 @@ /** 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_MIME_TYPES = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "video/mp4", + "video/quicktime", +]); const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); @@ -22,7 +28,7 @@ export function isWorkOrderMediaTypeAllowed(file: File): boolean { } export function workOrderMediaAcceptAttribute(): string { - return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov"; + return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov"; } export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { diff --git a/src/lib/auth/expire-session.ts b/src/lib/auth/expire-session.ts new file mode 100644 index 00000000..0c7c8900 --- /dev/null +++ b/src/lib/auth/expire-session.ts @@ -0,0 +1,12 @@ +import { clearAuth } from "@/lib/auth/auth-storage"; +import queryClient from "@/lib/query/query-client"; +import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast"; + +export function expireSessionAndRedirectToLogin(): void { + clearAuth(); + stopQueryBroadcast(queryClient); + + if (typeof window !== "undefined" && window.location.pathname !== "/login") { + window.location.replace("/login"); + } +} diff --git a/src/test/api/upload-form-with-progress.test.ts b/src/test/api/upload-form-with-progress.test.ts new file mode 100644 index 00000000..18a4f4dc --- /dev/null +++ b/src/test/api/upload-form-with-progress.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const expireSession = vi.fn(); + +vi.mock("@/lib/auth/expire-session", () => ({ + expireSessionAndRedirectToLogin: () => expireSession(), +})); + +import { uploadFormWithProgress } from "@/api/upload-form-with-progress"; + +type Listener = (event?: unknown) => void; + +class ListenerBag { + private readonly listeners = new Map(); + + addEventListener(type: string, listener: Listener): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener(type: string, listener: Listener): void { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter((entry) => entry !== listener), + ); + } + + emit(type: string, event?: unknown): void { + for (const listener of [...(this.listeners.get(type) ?? [])]) { + listener(event); + } + } +} + +class FakeXhr extends ListenerBag { + static last: FakeXhr | undefined; + + readonly upload = new ListenerBag(); + readonly headers: Record = {}; + status = 0; + responseText = ""; + method = ""; + url = ""; + body: FormData | undefined; + abortCount = 0; + + constructor() { + super(); + FakeXhr.last = this; + } + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + + setRequestHeader(name: string, value: string): void { + this.headers[name] = value; + } + + send(body: FormData): void { + this.body = body; + } + + abort(): void { + this.abortCount += 1; + this.emit("abort"); + } + + respond(status: number, responseText = ""): void { + this.status = status; + this.responseText = responseText; + this.emit("load"); + } +} + +function currentXhr(): FakeXhr { + const instance = FakeXhr.last; + if (!instance) throw new Error("No XMLHttpRequest was created"); + return instance; +} + +function formWithFile(): FormData { + const body = new FormData(); + body.append("file", new File(["bytes"], "after.jpg", { type: "image/jpeg" })); + return body; +} + +describe("uploadFormWithProgress", () => { + const originalXhr = globalThis.XMLHttpRequest; + + beforeEach(() => { + expireSession.mockReset(); + FakeXhr.last = undefined; + globalThis.XMLHttpRequest = FakeXhr as unknown as typeof XMLHttpRequest; + localStorage.setItem("auth", JSON.stringify({ token: "abc123" })); + }); + + afterEach(() => { + globalThis.XMLHttpRequest = originalXhr; + localStorage.clear(); + }); + + it("POSTs the FormData with the stored bearer token and no streamed body", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + const request = currentXhr(); + + expect(request.method).toBe("POST"); + expect(request.url.endsWith("/api/workorders/10/media")).toBe(true); + expect(request.headers.Authorization).toBe("Bearer abc123"); + expect(request.body).toBeInstanceOf(FormData); + + request.respond(201, JSON.stringify({ data: { id: 12 } })); + + await expect(pending).resolves.toEqual({ + ok: true, + status: 201, + data: { data: { id: 12 } }, + }); + }); + + it("reports upload progress as whole percentages", async () => { + const onProgress = vi.fn(); + const pending = uploadFormWithProgress("workorders/10/media", formWithFile(), { onProgress }); + const request = currentXhr(); + + request.upload.emit("progress", { lengthComputable: true, loaded: 25, total: 200 }); + request.upload.emit("progress", { lengthComputable: false, loaded: 30, total: 200 }); + request.respond(200, "{}"); + await pending; + + expect(onProgress.mock.calls).toEqual([[13]]); + }); + + it("resolves non-2xx responses so callers can map API errors", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + currentXhr().respond( + 422, + JSON.stringify({ code: "ReadOnly", message: "Work order is closed." }), + ); + + await expect(pending).resolves.toEqual({ + ok: false, + status: 422, + data: { code: "ReadOnly", message: "Work order is closed." }, + }); + expect(expireSession).not.toHaveBeenCalled(); + }); + + it("expires the session on 401", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + currentXhr().respond(401); + + await expect(pending).resolves.toMatchObject({ ok: false, status: 401 }); + expect(expireSession).toHaveBeenCalledTimes(1); + }); + + it("rejects transport failures with an actionable message", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + currentXhr().emit("error"); + + await expect(pending).rejects.toThrow(/Upload failed/); + }); + + it("aborts the request when the caller signal aborts", async () => { + const controller = new AbortController(); + const pending = uploadFormWithProgress("workorders/10/media", formWithFile(), { + signal: controller.signal, + }); + + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(currentXhr().abortCount).toBe(1); + }); + + it("rejects immediately when the signal is already aborted", async () => { + await expect( + uploadFormWithProgress("workorders/10/media", formWithFile(), { + signal: AbortSignal.abort(), + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(FakeXhr.last).toBeUndefined(); + }); +}); 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 index fd628353..b1fb4451 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -116,24 +116,42 @@ describe("MediaUploadModal API wiring", () => { 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(); }); - it("categorizes with workOrderVersion from detail.info.rowVersion", async () => { + it("offers no purpose picker — the table modal only uploads and removes", async () => { renderModal(); await waitFor(() => { - expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); + expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); - fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), { - target: { value: "After" }, + expect(screen.queryByLabelText(/category for before\.jpg/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); + expect(categorizeMutate).not.toHaveBeenCalled(); + }); + + it("shows only the file name under each tile — no purpose label", async () => { + renderModal(); + + await waitFor(() => { + expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); - expect(categorizeMutate).toHaveBeenCalledWith({ + expect(screen.queryByText("Before")).not.toBeInTheDocument(); + expect(screen.queryByText("Extra")).not.toBeInTheDocument(); + }); + + it("removes media with workOrderVersion from detail.info.rowVersion", async () => { + renderModal(); + + await waitFor(() => { + expect(screen.getByText("before.jpg")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /^remove$/i })); + + expect(deleteMutate).toHaveBeenCalledWith({ mediaId: "11", - category: "After", workOrderVersion: "DETAIL-VER==", }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index d3504fbc..de7e96ff 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from "@testing-library/react"; +import { act, 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"; @@ -119,6 +119,14 @@ describe("useSlideOverMediaState completion filter", () => { uploadedAt: "2026-07-15T18:00:00.0000000Z", isLegacy: false, }, + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, ]); const { result } = renderHook(() => @@ -134,7 +142,9 @@ describe("useSlideOverMediaState completion filter", () => { "Before", "After", "Completion", + "Extra", ]); + expect(result.current.completionMedia.map((item) => item.id)).toContain("10"); expect( result.current.completionMedia.every((item) => ["Photo", "Video", "Report", "Other"].includes(item.category), @@ -142,6 +152,32 @@ describe("useSlideOverMediaState completion filter", () => { ).toBe(true); expect(result.current.extrasUploads).toHaveLength(1); expect(result.current.extrasUploads[0]?.purpose).toBe("Extra"); + expect(result.current.extrasUploads[0]?.id).toBe("4"); + }); + + it("keeps Extra photos on Completion Doc until Extra is assigned", () => { + const detail = detailWithMedia([ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ]); + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.completionMedia.map((item) => item.id)).toEqual(["10"]); + expect(result.current.extrasUploads).toHaveLength(0); }); it("includes Aveta-categorized extra documents in extrasUploads", () => { @@ -177,4 +213,47 @@ describe("useSlideOverMediaState completion filter", () => { expect(result.current.extrasUploads.map((item) => item.purpose)).toEqual(["Extra", "Aveta"]); expect(result.current.extrasUploads[1]?.category).toBe("Aveta"); }); + + it("moves an Extra photo to Extra Docs after assigning Extra", () => { + const detail = detailWithMedia([ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ]); + const categorizeMutate = vi.fn(); + const categorize = { + mutate: categorizeMutate, + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + } as never; + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: categorize, + }), + ); + + expect(result.current.completionMedia).toHaveLength(1); + expect(result.current.extrasUploads).toHaveLength(0); + + act(() => { + result.current.categorizeMedia("10", "Extra"); + }); + + expect(result.current.completionMedia).toHaveLength(0); + expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]); + expect(categorizeMutate).toHaveBeenCalledWith({ + mediaId: "10", + category: "Extra", + workOrderVersion: "1", + }); + }); }); 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 e273324c..c466867d 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 @@ -271,4 +271,28 @@ describe("CompDocCell slice affordances", () => { expect(screen.getByRole("button", { name: /view doc/i })).toBeInTheDocument(); }); + + it("shows the uploaded file count for media work orders", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /2 files/i })).toBeInTheDocument(); + }); + + it("shows a singular label with one file and the Media prompt with none", () => { + const { unmount } = renderWithProviders( + , + { withAuth: false }, + ); + expect(screen.getByRole("button", { name: /^1 file$/i })).toBeInTheDocument(); + unmount(); + + renderWithProviders( + , + { withAuth: false }, + ); + expect(screen.getByRole("button", { name: /^media$/i })).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 d383aefc..7d7a3d1f 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 @@ -9,6 +9,11 @@ const apiPatch = vi.fn(); const apiPatchFn = vi.fn(); const apiPostFn = vi.fn(); const apiDeleteFn = vi.fn(); +const uploadFormFn = vi.fn(); + +vi.mock("@/api/upload-form-with-progress", () => ({ + uploadFormWithProgress: (...args: unknown[]) => uploadFormFn(...args), +})); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), @@ -654,44 +659,55 @@ describe("workOrdersApi.updateComment", () => { describe("workOrdersApi.uploadMedia", () => { beforeEach(() => { - apiPostFn.mockReset(); + uploadFormFn.mockReset(); }); - it("POSTs multipart FormData with file and category", async () => { - apiPostFn.mockResolvedValue({ + it("POSTs multipart FormData with file and category, forwarding progress and signal", async () => { + uploadFormFn.mockResolvedValue({ ok: true, - json: async () => ({ + status: 201, + data: { id: 12, category: 3, url: "https://cdn.example.com/extra.jpg", fileName: "extra.jpg", uploadedAt: "2026-07-16T12:00:00Z", isLegacy: false, - }), + }, }); const file = new File(["bytes"], "extra.jpg", { type: "image/jpeg" }); - const item = await workOrdersApi.uploadMedia(10, file, "Extra"); + const onProgress = vi.fn(); + const signal = new AbortController().signal; + const item = await workOrdersApi.uploadMedia(10, file, "Extra", { signal, onProgress }); - expect(apiPostFn).toHaveBeenCalledWith( - API_PATHS.workOrder.media(10), - expect.objectContaining({ throwHttpErrors: false }), - ); - const body = apiPostFn.mock.calls[0]?.[1]?.body as FormData; + expect(uploadFormFn).toHaveBeenCalledWith(API_PATHS.workOrder.media(10), expect.any(FormData), { + signal, + onProgress, + }); + const body = uploadFormFn.mock.calls[0]?.[1] as FormData; expect(body.get("category")).toBe("Extra"); expect(body.get("file")).toBeInstanceOf(File); expect(item.id).toBe("12"); expect(item.category).toBe("Extra"); }); + it("does not upload through Ky, whose streamed body breaks HTTP/1.1 transports", async () => { + uploadFormFn.mockResolvedValue({ ok: true, status: 200, data: { id: 12, category: 3 } }); + + await workOrdersApi.uploadMedia(10, new File(["x"], "a.jpg", { type: "image/jpeg" }), "Before"); + + expect(apiPostFn).not.toHaveBeenCalled(); + }); + it("throws BoardPatchValidationError on 422", async () => { - apiPostFn.mockResolvedValue({ + uploadFormFn.mockResolvedValue({ ok: false, status: 422, - json: async () => ({ + data: { code: "ReadOnly", message: "Work order is read-only.", - }), + }, }); const { BoardPatchValidationError } = 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 21867fa4..7f34bcc8 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 @@ -236,6 +236,20 @@ describe("mapWorkOrderBoardDetail", () => { }); }); + it("shows the uploaded file name instead of the stored guid name", () => { + const stored = mapBoardDetailMediaItem({ + id: 30, + category: 3, + url: "https://cdn.example.com/7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg", + fileName: "7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg", + }); + expect(stored.fileName).toBe("6370_1_Before.jpg"); + expect(boardMediaItemsToMediaFiles([stored])[0]).toMatchObject({ + name: "6370_1_Before.jpg", + fileType: "image", + }); + }); + 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-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 81b8e3e8..0b0e78c5 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -90,6 +90,25 @@ describe("mapWorkOrderTableRow avetaRequired", () => { }); }); +describe("mapWorkOrderTableRow mediaCount", () => { + it("maps the board mediaCount that feeds the CompDoc file count", () => { + const row = mapWorkOrderTableRow({ + id: 11, + workOrderType: WORK_ORDER_TYPE_API.Emergency, + mediaCount: 2, + }); + expect(row.mediaCount).toBe(2); + }); + + it("defaults mediaCount to zero when the board omits it", () => { + const row = mapWorkOrderTableRow({ + id: 12, + workOrderType: WORK_ORDER_TYPE_API.Emergency, + }); + expect(row.mediaCount).toBe(0); + }); +}); + describe("mapWorkOrderTableRow primaryDispatchStatus", () => { it.each([ ["primaryDispatchStatus", "Refused"], diff --git a/src/test/domain/work-orders/utils/display-file-name.test.ts b/src/test/domain/work-orders/utils/display-file-name.test.ts new file mode 100644 index 00000000..5c51f417 --- /dev/null +++ b/src/test/domain/work-orders/utils/display-file-name.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name"; + +describe("toDisplayFileName", () => { + it("drops the storage guid prefix and keeps the original name", () => { + expect(toDisplayFileName("7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg")).toBe( + "6370_1_Before.jpg", + ); + }); + + it("matches the prefix regardless of guid casing", () => { + expect(toDisplayFileName("7A0CF3B5-3EEB-4C4F-A86E-8E2DA1B0C9F4_report.pdf")).toBe("report.pdf"); + }); + + it("keeps names that do not carry a guid prefix", () => { + expect(toDisplayFileName("before.jpg")).toBe("before.jpg"); + expect(toDisplayFileName("2026_summary_report.pdf")).toBe("2026_summary_report.pdf"); + }); + + it("falls back to the raw name when nothing follows the prefix", () => { + const onlyPrefix = "7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_"; + expect(toDisplayFileName(onlyPrefix)).toBe(onlyPrefix); + }); +}); diff --git a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts new file mode 100644 index 00000000..077eff57 --- /dev/null +++ b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import { + isCompletionSurfaceMedia, + isExtrasSurfaceMedia, +} from "@/domain/work-orders/utils/partition-slide-over-media"; + +function file( + overrides: Partial & Pick, +): MediaFile { + return { + name: overrides.name ?? overrides.id, + url: "https://cdn.example.com/file", + category: "Photo", + uploadStatus: "done", + ...overrides, + }; +} + +describe("partition-slide-over-media", () => { + it("keeps unowned Extra photos on Completion Doc, not Extra Docs", () => { + const photo = file({ + id: "10", + fileType: "image", + purpose: "Extra", + name: "site.jpg", + }); + + expect(isCompletionSurfaceMedia(photo)).toBe(true); + expect(isExtrasSurfaceMedia(photo)).toBe(false); + }); + + it("keeps Extra PDFs on Extra Docs, not Completion Doc", () => { + const pdf = file({ + id: "4", + fileType: "pdf", + purpose: "Extra", + name: "extra.pdf", + category: "Other", + }); + + expect(isCompletionSurfaceMedia(pdf)).toBe(false); + expect(isExtrasSurfaceMedia(pdf)).toBe(true); + }); + + it("moves Extra photo to Extra Docs after explicit Extra ownership", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(), + extraOwnedIds: new Set(["10"]), + }; + + expect(isCompletionSurfaceMedia(photo, ownership)).toBe(false); + expect(isExtrasSurfaceMedia(photo, ownership)).toBe(true); + }); + + it("keeps Extra photo on Completion Doc while completion-owned", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(["10"]), + extraOwnedIds: new Set(), + }; + + expect(isCompletionSurfaceMedia(photo, ownership)).toBe(true); + expect(isExtrasSurfaceMedia(photo, ownership)).toBe(false); + }); + + it("keeps Before/After/Completion and in-flight tiles on Completion Doc", () => { + expect(isCompletionSurfaceMedia(file({ id: "1", fileType: "image", purpose: "Before" }))).toBe( + true, + ); + expect( + isCompletionSurfaceMedia({ + ...file({ id: "local-1", fileType: "image", purpose: null }), + uploadStatus: "uploading", + }), + ).toBe(true); + }); +}); 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 2ae1f827..49af4e90 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 @@ -27,6 +27,7 @@ describe("work-order-media-limits", () => { 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.jpg", type: "image/jpg" } 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( From 747515870e107dedbe64d7646d6c45b60d5e0b23 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 7 Sep 2026 20:48:30 -0300 Subject: [PATCH 10/25] fix(workorders): correct vendor save behavior --- .../table/cells/use-vendor-dialog-form.ts | 177 ++++++++++++++++++ .../table/cells/use-vendor-dialog-save.ts | 7 +- .../list/table/cells/vendor-dialog-body.tsx | 59 ++++-- .../table/cells/vendor-dialog-can-submit.ts | 27 +++ .../cells/vendor-dialog-company-card.tsx | 7 +- .../list/table/cells/vendor-dialog-footer.tsx | 15 +- .../list/table/cells/vendor-dialog-options.ts | 50 ++++- .../list/table/cells/vendor-dialog.tsx | 143 ++++---------- .../work-orders/errors/board-patch-errors.ts | 8 + .../use-work-order-table-mutations.ts | 3 +- .../uplifts/reject-revoke-dialogs.test.tsx | 7 +- .../vendor-dialog-can-submit.test.ts | 95 ++++++++++ .../vendor-dialog-save-gate.test.tsx | 158 ++++++++++++++++ .../vendor-dialog-stable-identity.test.tsx | 4 +- ...er-table-mutations-assigned-toast.test.tsx | 35 ++++ 15 files changed, 652 insertions(+), 143 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit.ts create mode 100644 src/test/app/(protected)/workorders/vendor-dialog-can-submit.test.ts create mode 100644 src/test/app/(protected)/workorders/vendor-dialog-save-gate.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts new file mode 100644 index 00000000..3bd9104b --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from "react"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; +import { + buildCompanyOptions, + buildTechnicianOptions, + phoneForOption, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; +import { + mergeVendorDropdownItems, + resolveVendorCompanyAnchor, +} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor"; +import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key"; +import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save"; +import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit"; +import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; + +type UseVendorDialogFormArgs = { + open: boolean; + onOpenChange: (open: boolean) => void; + vendorId: string | number; + company: string; + tech: string; + techPhone: string; + vendors: VendorDropdownItem[]; + onSave: (patch: VendorPatch) => void; +}; + +export function useVendorDialogForm({ + open, + onOpenChange, + vendorId, + company, + tech, + techPhone, + vendors, + onSave, +}: UseVendorDialogFormArgs) { + const createVendor = useCreateVendor(); + const [selectedCompany, setSelectedCompany] = useState(company); + const [selectedKey, setSelectedKey] = useState(() => + resolveTechnicianKey(vendorId, tech, company, vendors), + ); + const [t, setT] = useState(tech); + const [phone, setPhone] = useState(techPhone); + const [notes, setNotes] = useState(""); + const [addedVendors, setAddedVendors] = useState([]); + const [pendingCreates, setPendingCreates] = useState>({}); + const [saveError, setSaveError] = useState(null); + + const allVendors = useMemo( + () => mergeVendorDropdownItems(vendors, addedVendors), + [vendors, addedVendors], + ); + + const companyOptions = useMemo( + () => buildCompanyOptions(allVendors, company), + [allVendors, company], + ); + + const technicianOptions = useMemo( + () => buildTechnicianOptions(allVendors, selectedCompany, tech, company), + [allVendors, selectedCompany, tech, company], + ); + + useEffect(() => { + if (open) { + const key = resolveTechnicianKey(vendorId, tech, company, vendors); + const initialOptions = buildTechnicianOptions(vendors, company, tech, company); + setSelectedCompany(company); + setSelectedKey(key); + setT(tech); + setPhone(techPhone || phoneForOption(tech, initialOptions)); + setNotes(""); + setAddedVendors([]); + setPendingCreates({}); + setSaveError(null); + } + // invariant: rebuilding options after Add must not clear staged technician drafts. + }, [open, tech, techPhone, vendors, company, vendorId]); + + const selected = technicianOptions.find((option) => option.key === selectedKey); + const resolvedCompany = selectedCompany; + const selectedVendor = + selected?.vendor ?? allVendors.find((vendor) => vendor.companyName === selectedCompany); + + const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({ + company: selectedCompany, + resolvedCompany, + selectedVendor, + vendors: allVendors, + }); + + const canSubmit = computeVendorDialogCanSubmit({ + current: { + company: selectedCompany, + selectedKey, + phone, + notes, + pendingCreateCount: Object.keys(pendingCreates).length, + }, + baseline: { + company, + selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors), + phone: + techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)), + notes: "", + pendingCreateCount: 0, + }, + }); + + const { handleSave } = useVendorDialogSave({ + allVendors, + pendingCreates, + selectedKey, + selectedVendor, + resolvedCompany, + anchorCompanyName, + tech: selected?.name ?? t, + phone, + createVendor, + onSave, + onOpenChange, + setSaveError, + canSubmit, + }); + + return { + companyOptions, + technicianOptions, + selected, + t, + phone, + notes, + resolvedCompany, + selectedVendor, + selectedCompany, + selectedKey, + anchorCompanyName, + companyVendor, + saveError, + canSubmit, + isPending: createVendor.isPending, + handleSave, + handleCompanyPick: (nextCompany: string) => { + const selectedOption = technicianOptions.find((option) => option.key === selectedKey); + setSelectedCompany(nextCompany); + setAddedVendors([]); + setPendingCreates({}); + if (selectedOption?.company !== nextCompany) { + setSelectedKey(""); + setT(""); + setPhone(""); + } + }, + handlePick: (key: string) => { + const option = technicianOptions.find((o) => o.key === key); + setSelectedKey(key); + setT(option?.name ?? key); + setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions)); + }, + handleTechnicianCreated: ( + created: VendorDropdownItem, + createdPhone: string, + payload: VendorFormValues, + ) => { + setAddedVendors((current) => [...current, created]); + setPendingCreates((current) => ({ ...current, [String(created.id)]: payload })); + setSelectedKey(String(created.id)); + setT(created.contactName); + setPhone(createdPhone); + }, + setPhone, + setNotes, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts index 502714c6..619bc6c9 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts @@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = { onSave: (patch: VendorPatch) => void; onOpenChange: (open: boolean) => void; setSaveError: (error: string | null) => void; + canSubmit: boolean; }; export function useVendorDialogSave({ @@ -33,6 +34,7 @@ export function useVendorDialogSave({ onSave, onOpenChange, setSaveError, + canSubmit, }: UseVendorDialogSaveArgs) { const resolveSaveVendor = () => allVendors.find((vendor) => String(vendor.id) === selectedKey) ?? @@ -44,6 +46,7 @@ export function useVendorDialogSave({ selectedVendor; const handleSave = async () => { + if (!canSubmit) return; setSaveError(null); let saveVendor = resolveSaveVendor(); const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined; @@ -65,8 +68,8 @@ export function useVendorDialogSave({ onSave({ vendorId: saveVendor ? String(saveVendor.id) : "", company: resolvedCompany || anchorCompanyName, - tech: saveVendor?.contactName ?? tech, - techPhone: phone, + tech: selectedKey ? (saveVendor?.contactName ?? tech) : "", + techPhone: selectedKey ? phone : "", }); onOpenChange(false); }; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx index 5b58de5e..928bfe51 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx @@ -1,4 +1,4 @@ -import { Wrench } from "lucide-react"; +import { Building2, Wrench } from "lucide-react"; import { DLG_INPUT_CLS, DLG_INPUT_STYLE, @@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/ import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician"; import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; +import type { + CompanyOption, + TechnicianOption, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; type VendorDialogBodyProps = { + selectedCompany: string; + companyOptions: CompanyOption[]; selectedKey: string; technicianOptions: TechnicianOption[]; anchorCompanyName: string; @@ -23,6 +28,7 @@ type VendorDialogBodyProps = { resolvedCompany: string; selectedVendor: VendorDropdownItem | undefined; saveError: string | null; + onCompanyPick: (company: string) => void; onPick: (key: string) => void; onPhoneChange: (phone: string) => void; onNotesChange: (notes: string) => void; @@ -34,6 +40,8 @@ type VendorDialogBodyProps = { }; export function VendorDialogBody({ + selectedCompany, + companyOptions, selectedKey, technicianOptions, anchorCompanyName, @@ -44,6 +52,7 @@ export function VendorDialogBody({ resolvedCompany, selectedVendor, saveError, + onCompanyPick, onPick, onPhoneChange, onNotesChange, @@ -53,17 +62,46 @@ export function VendorDialogBody({ <>
- Technician + Company + + } + searchPlaceholder="Search company…" + options={companyOptions.map((option) => ({ + key: option.key, + label: option.label, + }))} + onPick={onCompanyPick} + /> +
+ + {Boolean(resolvedCompany) && ( +
+ Company info + +
+ )} + +
+ Technician (optional) } searchPlaceholder="Search technician…" options={technicianOptions.map((option) => ({ key: option.key, - label: option.label, + label: option.name, }))} onPick={onPick} /> @@ -80,7 +118,7 @@ export function VendorDialogBody({ {Boolean(tech) && (
- Technician Phone + Technician Phone (optional) )} - {Boolean(resolvedCompany) && ( -
- Company - -
- )} -
- Notes + Notes (optional)