@@ -135,9 +138,10 @@ export function WizardStepTypeSchedule({
WO Number
onUpdate("woNumber", e.target.value)}
- placeholder="e.g. 24818"
+ onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))}
+ placeholder="e.g. 12345678901"
maxLength={20}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
@@ -150,13 +154,17 @@ export function WizardStepTypeSchedule({
value={draft.assigneeId}
placeholder="Select…"
icon={
-
+ selectedUser ? (
+
+ ) : (
+
+ )
}
searchPlaceholder="Search dispatcher…"
options={users.map((u) => ({
key: String(u.id),
label: u.name,
- left: ,
+ left: ,
}))}
onPick={(id) => {
const user = users.find((u) => String(u.id) === id);
@@ -173,8 +181,15 @@ export function WizardStepTypeSchedule({
onPatch({ scheduledOn: "", scheduleWeekOnly: false })
+ draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
+ ? () =>
+ onPatch({
+ scheduledOn: "",
+ scheduleWeekOnly: false,
+ scheduleEnd: "",
+ schedMode: undefined,
+ targetWeek: "",
+ })
: undefined
}
>
@@ -182,11 +197,38 @@ export function WizardStepTypeSchedule({
onPatch({ scheduledOn: iso, scheduleWeekOnly: false })}
- onChangeWeek={(w) => onPatch({ scheduledOn: w, scheduleWeekOnly: Boolean(w) })}
+ scheduleEnd={draft.scheduleEnd}
+ schedMode={draft.schedMode}
+ onChange={(iso) =>
+ onPatch({
+ scheduledOn: iso,
+ scheduleWeekOnly: false,
+ scheduleEnd: "",
+ schedMode: "date",
+ targetWeek: "",
+ })
+ }
+ onChangeWeek={(w) =>
+ onPatch({
+ scheduledOn: w,
+ scheduleWeekOnly: Boolean(w),
+ scheduleEnd: "",
+ schedMode: "week",
+ targetWeek: w,
+ })
+ }
+ onChangeRange={(s, e) =>
+ onPatch({
+ scheduledOn: s,
+ scheduleEnd: e,
+ schedMode: "range",
+ scheduleWeekOnly: false,
+ targetWeek: "",
+ })
+ }
/>
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
index d41c939a..7dbcb199 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
@@ -1,21 +1,21 @@
-import { Clock, ExternalLink, Mail, Wrench } from "lucide-react";
+import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
- ALL_WIZARD_STATUSES,
TIME_SLOTS,
- WIZARD_STATUS_DOTS,
- type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
+import {
+ formatVendorDistance,
+ formatVendorTechnicianLabel,
+} from "@/domain/work-orders/utils/vendor-dropdown-label";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import {
- ColorDot,
Detail,
WizLabel,
WizLabelRow,
@@ -33,11 +33,13 @@ function WizardVendorDetail({
vendor,
techPhone,
vendorNotes,
+ distance,
onUpdate,
}: {
vendor: VendorDropdownItem;
techPhone: string;
vendorNotes: string;
+ distance: string;
onUpdate:
(key: K, value: WorkOrderWizardDraft[K]) => void;
}) {
return (
@@ -61,9 +63,20 @@ function WizardVendorDetail({
border: "1px solid var(--color-border)",
}}
>
-
- {vendor.companyName || "—"}
+
+
+ {vendor.companyName || "—"}
+
+ {Boolean(distance) && (
+
+ {distance}
+
+ )}
+ {Boolean(vendor.contactName) &&
}
+ {Boolean(vendor.tradeSpecialties) && (
+
+ )}
{Boolean(vendor.address) &&
}
{Boolean(vendor.address) && (
String(v.id) === draft.vendorId);
- const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
- const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
+ const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
return (
@@ -133,7 +145,9 @@ export function WizardStepVendorTime({
color: "var(--color-text-secondary)",
}}
>
- {selectedVendor.companyName.slice(0, 2).toUpperCase()}
+ {(selectedVendor.contactName || selectedVendor.companyName)
+ .slice(0, 2)
+ .toUpperCase()}
) : (
@@ -142,7 +156,7 @@ export function WizardStepVendorTime({
searchPlaceholder="Search technician…"
options={vendors.map((v) => ({
key: String(v.id),
- label: v.companyName,
+ label: formatVendorTechnicianLabel(v),
}))}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
@@ -160,6 +174,7 @@ export function WizardStepVendorTime({
vendor={selectedVendor}
techPhone={draft.techPhone}
vendorNotes={draft.vendorNotes}
+ distance={selectedDistance}
onUpdate={onUpdate}
/>
)}
@@ -213,22 +228,6 @@ export function WizardStepVendorTime({
)}
-
-
- Status
- }
- options={ALL_WIZARD_STATUSES.map((s) => ({
- key: s,
- label: s,
- left: ,
- }))}
- onPick={(s) => onUpdate("status", s as WizardWOStatus)}
- />
-
);
}
diff --git a/src/app/(protected)/workorders/_components/work-order-form.tsx b/src/app/(protected)/workorders/_components/work-order-form.tsx
index 35e3afa7..eddb9511 100644
--- a/src/app/(protected)/workorders/_components/work-order-form.tsx
+++ b/src/app/(protected)/workorders/_components/work-order-form.tsx
@@ -3,8 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router";
-import { Box, Breadcrumbs, Button, CircularProgress, Link, Paper, Stack } from "@mui/material";
+import { Box, Breadcrumbs, Button, Link, Paper, Stack } from "@mui/material";
import { Text } from "@/components/ui/text";
+import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
@@ -28,6 +29,7 @@ import {
WorkOrderDetailsFields,
} from "@/app/(protected)/workorders/_components/work-order-fields";
import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section";
+import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton";
type ScheduleBounds = {
kind: "valid" | "invalid" | "open";
@@ -125,7 +127,12 @@ function useWorkOrderFormState() {
const isEdit = Boolean(id);
const assignVendorRequested = searchParams.get("assignVendor") === "1";
- const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
+ const {
+ data: detail,
+ isLoading: isLoadingDetail,
+ error: detailError,
+ refetch: refetchDetail,
+ } = useWorkOrderDetail(id);
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
@@ -225,6 +232,7 @@ function useWorkOrderFormState() {
detail,
isLoading,
detailError,
+ refetchDetail,
isSaving: saveMutation.isPending,
register,
control,
@@ -264,6 +272,7 @@ export default function WorkOrderFormPage() {
detail,
isLoading,
detailError,
+ refetchDetail,
isSaving,
register,
control,
@@ -296,17 +305,16 @@ export default function WorkOrderFormPage() {
} = useWorkOrderFormState();
if (isLoading) {
- return (
-
-
-
- );
+ return ;
}
if (detailError) {
return (
- Unable to load this work order. Please try again.
+ void refetchDetail()}
+ />
);
}
diff --git a/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts b/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts
index 3a6c656d..c2db6670 100644
--- a/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts
+++ b/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts
@@ -2,7 +2,11 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/wor
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
-/** Mutates `payload` with `dueDate` and day/week schedule fields (range mode has no schedule fields). */
+/**
+ * Mutates `payload` with `dueDate` and schedule fields.
+ * Range mode lands on the board using the range start as `scheduledDate`
+ * (prototype `hasScheduleDate`: a range start qualifies as scheduled).
+ */
export function assignWizardScheduleFields(
payload: WorkOrderBoardCreatePayload,
draft: WorkOrderWizardDraft,
@@ -21,7 +25,7 @@ export function assignWizardScheduleFields(
if (week) {
payload.targetWeek = week;
}
- } else if (!isRange && draft.scheduledOn) {
+ } else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) {
payload.scheduledDate = draft.scheduledOn;
}
}
diff --git a/src/test/app/(protected)/workorders/use-wizard-poc-autofill.test.tsx b/src/test/app/(protected)/workorders/use-wizard-poc-autofill.test.tsx
new file mode 100644
index 00000000..5f9113b8
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-wizard-poc-autofill.test.tsx
@@ -0,0 +1,78 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
+import type { Location } from "@/domain/locations/types/location";
+import {
+ EMPTY_WIZARD_DRAFT,
+ type WorkOrderWizardDraft,
+} from "@/domain/work-orders/types/work-order-wizard";
+import { useState } from "react";
+
+function baseLocation(overrides: Partial = {}): Location {
+ return {
+ id: "loc-1",
+ name: "BK5",
+ contact: "Janelle Pickett",
+ phone: "214-555-0111",
+ ...overrides,
+ } as Location;
+}
+
+describe("useWizardPocAutofill", () => {
+ it("fills empty POC fields from location detail once", () => {
+ const { result } = renderHook(() => {
+ const [draft, setDraft] = useState({ ...EMPTY_WIZARD_DRAFT });
+ useWizardPocAutofill(true, "loc-1", baseLocation(), setDraft);
+ return draft;
+ });
+
+ expect(result.current.pocName).toBe("Janelle Pickett");
+ expect(result.current.pocPhone).toBe("214-555-0111");
+ });
+
+ it("does not overwrite POC edits made before location detail arrives", () => {
+ const { result, rerender } = renderHook(
+ ({ detail }: { detail: Location | undefined }) => {
+ const [draft, setDraft] = useState({
+ ...EMPTY_WIZARD_DRAFT,
+ pocName: "Dispatcher Typed",
+ pocPhone: "999-000-1111",
+ });
+ useWizardPocAutofill(true, "loc-1", detail, setDraft);
+ return draft;
+ },
+ { initialProps: { detail: undefined as Location | undefined } },
+ );
+
+ expect(result.current.pocName).toBe("Dispatcher Typed");
+
+ act(() => {
+ rerender({ detail: baseLocation() });
+ });
+
+ expect(result.current.pocName).toBe("Dispatcher Typed");
+ expect(result.current.pocPhone).toBe("999-000-1111");
+ });
+
+ it("re-seeds POC after re-selecting the same location clears the fields", () => {
+ const { result } = renderHook(() => {
+ const [draft, setDraft] = useState({ ...EMPTY_WIZARD_DRAFT });
+ const autofill = useWizardPocAutofill(true, "loc-1", baseLocation(), setDraft);
+ return { draft, setDraft, autofill };
+ });
+
+ expect(result.current.draft.pocName).toBe("Janelle Pickett");
+
+ act(() => {
+ result.current.setDraft((current) => ({
+ ...current,
+ pocName: "",
+ pocPhone: "",
+ }));
+ result.current.autofill.notifyLocationChanged("loc-1");
+ });
+
+ expect(result.current.draft.pocName).toBe("Janelle Pickett");
+ expect(result.current.draft.pocPhone).toBe("214-555-0111");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx
new file mode 100644
index 00000000..7ef8223c
--- /dev/null
+++ b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx
@@ -0,0 +1,110 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
+import {
+ EMPTY_WIZARD_DRAFT,
+ type WorkOrderWizardDraft,
+} from "@/domain/work-orders/types/work-order-wizard";
+import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
+
+function baseDraft(overrides: Partial = {}): WorkOrderWizardDraft {
+ return { ...EMPTY_WIZARD_DRAFT, type: "PM", ...overrides };
+}
+
+function currentMonthIso(day: number): string {
+ const [y, m] = todayIso().split("-").map(Number);
+ return `${y}-${pad2(m)}-${pad2(day)}`;
+}
+
+async function openScheduleRangePicker() {
+ fireEvent.click(screen.getByRole("button", { name: /pick a date/i }));
+ const rangeTab = await screen.findByRole("button", { name: "Range" });
+ fireEvent.click(rangeTab);
+}
+
+describe("WizardStepTypeSchedule — Schedule On range selection", () => {
+ it("clicks start then end in order and submits schedMode range with matching dates", async () => {
+ const onUpdate = vi.fn();
+ const onPatch = vi.fn();
+
+ render(
+ ,
+ );
+
+ await openScheduleRangePicker();
+
+ const startIso = currentMonthIso(5);
+ const endIso = currentMonthIso(20);
+
+ 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({
+ scheduledOn: startIso,
+ scheduleEnd: endIso,
+ schedMode: "range",
+ scheduleWeekOnly: false,
+ targetWeek: "",
+ });
+ });
+
+ it("normalizes an out-of-order click (end clicked before start) into start {
+ const onUpdate = vi.fn();
+ const onPatch = vi.fn();
+
+ render(
+ ,
+ );
+
+ await openScheduleRangePicker();
+
+ const laterIso = currentMonthIso(20);
+ const earlierIso = currentMonthIso(5);
+
+ // User clicks the later date first, then an earlier date — the field must
+ // normalize this into (start = earlier, end = later) before submission.
+ fireEvent.click(screen.getByRole("button", { name: "20" }));
+ fireEvent.click(screen.getByRole("button", { name: "5" }));
+ fireEvent.click(screen.getByRole("button", { name: /^done$/i }));
+
+ expect(onPatch).toHaveBeenCalledWith({
+ scheduledOn: earlierIso,
+ scheduleEnd: laterIso,
+ schedMode: "range",
+ scheduleWeekOnly: false,
+ targetWeek: "",
+ });
+ });
+
+ it("does not submit until both a start and end date are picked", async () => {
+ const onUpdate = vi.fn();
+ const onPatch = vi.fn();
+
+ render(
+ ,
+ );
+
+ await openScheduleRangePicker();
+
+ fireEvent.click(screen.getByRole("button", { name: "5" }));
+
+ expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled();
+ expect(onPatch).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx
new file mode 100644
index 00000000..31007cda
--- /dev/null
+++ b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx
@@ -0,0 +1,48 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select";
+import {
+ EMPTY_WIZARD_DRAFT,
+ type WorkOrderWizardDraft,
+} from "@/domain/work-orders/types/work-order-wizard";
+
+describe("WizardStepLocationServiceSelect — vendor clear on service change", () => {
+ it("clears vendor fields when the primary service changes", () => {
+ const onPatch = vi.fn();
+ const draft: WorkOrderWizardDraft = {
+ ...EMPTY_WIZARD_DRAFT,
+ pm: "HVAC",
+ vendorId: "9",
+ vendorName: "Old Vendor",
+ techPhone: "555-0100",
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
+ fireEvent.click(screen.getByRole("button", { name: "Plumbing" }));
+
+ expect(onPatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ pm: "Plumbing",
+ vendorId: "",
+ vendorName: "",
+ techPhone: "",
+ }),
+ );
+ });
+});
diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
index 3d8034a7..fcb12c9a 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
@@ -254,7 +254,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
).toThrow("Site code is required");
});
- it("omits scheduledDate for range schedule and puts range in description only", () => {
+ it("sets scheduledDate from range start and keeps range text in description", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
@@ -273,7 +273,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
vendorNotes: "Also not in description",
});
- expect(payload.scheduledDate).toBeUndefined();
+ expect(payload.scheduledDate).toBe("2026-06-08");
expect(payload.scheduleWeekOnly).toBeUndefined();
diff --git a/src/test/domain/work-orders/utils/wizard-date-utils.test.ts b/src/test/domain/work-orders/utils/wizard-date-utils.test.ts
index 8f63bbbf..3ee9c66a 100644
--- a/src/test/domain/work-orders/utils/wizard-date-utils.test.ts
+++ b/src/test/domain/work-orders/utils/wizard-date-utils.test.ts
@@ -1,10 +1,8 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import {
fmtDateRange,
fmtShortDate,
- pad2,
rangeDayCount,
- todayIso,
} from "@/domain/work-orders/utils/wizard-date-utils";
describe("fmtShortDate", () => {
@@ -31,27 +29,3 @@ describe("rangeDayCount", () => {
expect(rangeDayCount("2026-06-08", "2026-06-08")).toBe(1);
});
});
-
-describe("todayIso", () => {
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it("uses local calendar day, not UTC ISO date, near midnight", () => {
- // 02:30 UTC is still the previous local evening in Americas (UTC−N).
- vi.useFakeTimers();
- vi.setSystemTime(new Date("2026-08-11T02:30:00.000Z"));
-
- const now = new Date();
- const expectedLocal = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
- const utcSlice = now.toISOString().slice(0, 10);
-
- expect(todayIso()).toBe(expectedLocal);
- expect(utcSlice).toBe("2026-08-11");
-
- if (now.getTimezoneOffset() > 0) {
- expect(todayIso()).toBe("2026-08-10");
- expect(todayIso()).not.toBe(utcSlice);
- }
- });
-});
From 6fe1cd0f27b71244277634865df35ad0b0541e04 Mon Sep 17 00:00:00 2001
From: Arthur Bassi <201806768+bassi-arthurr@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:12:14 -0300
Subject: [PATCH 02/14] fix(work-orders): remove legacy completedDate
EditWorkorder path
[recover] remove malicious eslint payload (was f4e6132b)
---
docs/adr/0001-query-broadcast-client.md | 81 ++++
package-lock.json | 104 ++++-
package.json | 5 +-
scripts/governance-baseline.json | 5 -
src/api/api.ts | 3 +-
src/app/(protected)/workorders/[id].tsx | 136 +++----
.../comp-doc-dialog-footer-draft-actions.tsx | 5 +-
.../tabs/slide-over-info-tab-display-view.tsx | 6 +-
.../tabs/slide-over-info-tab-vendor-field.tsx | 42 +-
.../detail/tabs/slide-over-info-tab.tsx | 1 -
.../_components/dispatch-create-modal.tsx | 377 ------------------
.../dispatch-create-checklist-fields.tsx | 84 ++++
.../dispatch-create-details-fields.tsx | 38 ++
.../dispatch-create-vendor-section.tsx | 110 +++++
.../dispatch-create-work-orders-section.tsx | 73 ++++
.../dispatch-create-modal/index.tsx | 114 ++++++
.../dispatch-checklist-section.tsx | 8 +-
.../dispatch-detail/dispatch-detail-body.tsx | 100 +++++
.../dispatch-detail/dispatch-edit-form.tsx | 17 +-
.../dispatch-signoffs-section.tsx | 6 +-
.../dispatch-vendor-communication.tsx | 10 +-
.../dispatch-verify-footer.tsx | 14 +-
.../dispatch/dispatch-detail/index.tsx | 113 ++----
.../uplift-decision-dialog.tsx | 19 +-
.../view/work-order-activity-panel.tsx | 73 ++++
.../view/work-order-details-panel.tsx | 76 ++++
.../view/work-order-dispatches-accordion.tsx | 117 ++++++
.../view/work-order-header-section.tsx | 113 ++++++
.../view/work-order-info-accordions.tsx | 68 ++++
.../_components/view/work-order-timelines.tsx | 125 ++++++
.../view/work-order-vendor-editor-dialog.tsx | 101 +++++
.../wizard/use-wizard-date-field-state.ts | 14 +-
.../wizard-step-location-site-select.tsx | 1 -
.../wizard/wizard-step-vendor-time.tsx | 22 +
.../_hooks/use-dispatch-create-controller.ts | 159 ++++++++
.../_hooks/use-dispatch-detail-controller.ts | 61 ++-
.../_hooks/use-dispatch-uplift-controller.ts | 51 +++
.../workorders/_hooks/use-vendor-editor.ts | 114 ++++++
.../_hooks/use-work-order-view-controller.ts | 82 ++++
.../common/signature-capture-fields.tsx | 91 +++++
src/components/common/signature-capture.tsx | 183 ++-------
src/components/common/use-signature-canvas.ts | 85 ++++
src/domain/auth/api/auth-api.ts | 13 -
.../mocks/is-wo-wizard-mock-enabled.ts | 3 +-
.../use-cases/use-work-order-mutations.ts | 4 +-
src/lib/auth/auth-bypass.ts | 36 --
src/lib/env.ts | 1 -
src/lib/query/query-client.ts | 3 +
src/lib/query/setup-query-broadcast.ts | 19 +
.../common/signature-capture.test.tsx | 62 +++
.../lib/query/setup-query-broadcast.test.ts | 47 +++
src/test/setup.ts | 35 +-
src/vite-env.d.ts | 2 -
53 files changed, 2367 insertions(+), 865 deletions(-)
create mode 100644 docs/adr/0001-query-broadcast-client.md
delete mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-checklist-fields.tsx
create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-details-fields.tsx
create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-vendor-section.tsx
create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-work-orders-section.tsx
create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/index.tsx
create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-body.tsx
create mode 100644 src/app/(protected)/workorders/_components/view/work-order-activity-panel.tsx
create mode 100644 src/app/(protected)/workorders/_components/view/work-order-details-panel.tsx
create mode 100644 src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx
create mode 100644 src/app/(protected)/workorders/_components/view/work-order-header-section.tsx
create mode 100644 src/app/(protected)/workorders/_components/view/work-order-info-accordions.tsx
create mode 100644 src/app/(protected)/workorders/_components/view/work-order-timelines.tsx
create mode 100644 src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx
create mode 100644 src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts
create mode 100644 src/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller.ts
create mode 100644 src/app/(protected)/workorders/_hooks/use-vendor-editor.ts
create mode 100644 src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts
create mode 100644 src/components/common/signature-capture-fields.tsx
create mode 100644 src/components/common/use-signature-canvas.ts
delete mode 100644 src/lib/auth/auth-bypass.ts
create mode 100644 src/lib/query/setup-query-broadcast.ts
create mode 100644 src/test/components/common/signature-capture.test.tsx
create mode 100644 src/test/lib/query/setup-query-broadcast.test.ts
diff --git a/docs/adr/0001-query-broadcast-client.md b/docs/adr/0001-query-broadcast-client.md
new file mode 100644
index 00000000..4ced20f2
--- /dev/null
+++ b/docs/adr/0001-query-broadcast-client.md
@@ -0,0 +1,81 @@
+# 0001. Cross-tab QueryClient sync via `@tanstack/query-broadcast-client-experimental`
+
+## Status
+
+Accepted
+
+## Context
+
+The SeaHaven admin SPA is frequently used with multiple browser tabs open
+against the same work-order board (e.g. a dispatcher triaging the board in one
+tab while editing a work order in another). Each tab owns its own TanStack
+Query `QueryClient` cache, so a mutation performed in one tab (status change,
+dispatch creation, comment, vendor patch, etc.) does not invalidate or update
+the cache in sibling tabs. Users were seeing stale board/detail data until a
+manual refresh or the next background refetch.
+
+We need a way to keep `QueryClient` caches roughly in sync across tabs of the
+same origin, without introducing a new state-management layer (Redux is
+disallowed by this repo's conventions) or a server-push mechanism.
+
+## Decision
+
+Use TanStack's own experimental broadcast client,
+[`@tanstack/query-broadcast-client-experimental`](https://tanstack.com/query),
+wired up in a single dedicated setup module,
+`src/lib/query/setup-query-broadcast.ts`. The module:
+
+- Wraps `broadcastQueryClient({ queryClient, broadcastChannel })`, keyed to a
+ single named channel (`seahaven-admin-query`).
+- No-ops outside the browser (SSR/build) and under Vitest (`import.meta.env.MODE
+=== "test"`), so it never runs in unit tests or node-based tooling.
+- Is invoked once from `src/lib/query/query-client.ts` against the app's
+ singleton `QueryClient`, so every tab that loads the SPA subscribes to the
+ same channel automatically — no per-feature wiring required.
+
+This piggybacks on the query cache we already have (no parallel store), uses
+the library that owns the `QueryClient` we already depend on, and requires
+close to zero application code (~20 lines) to adopt.
+
+### Alternatives considered
+
+- **Custom `BroadcastChannel` + manual `queryClient.invalidateQueries` calls**
+ — full control over payloads, but requires hand-rolling
+ serialization/versioning of query keys and mutation results, and keeping
+ every future mutation hook wired to broadcast. More code to own and more
+ surface area for subtle cache-desync bugs.
+- **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem
+ unresolved; users would need to manually refresh or wait for
+ `refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
+ for a live dispatch board.
+- **Full WebSocket-based real-time sync** — solves both cross-tab and
+ cross-user staleness, but is a materially larger investment (server-side
+ push infra, connection lifecycle, auth over the socket) that is out of scope
+ for the current admin SPA and not justified by the actual problem (same
+ browser, same user, same origin).
+
+## Consequences
+
+- **Positive**: sibling tabs reflect mutations (status changes, dispatch
+ actions, comments, patches) without a manual refresh; the integration is
+ isolated to one setup file and does not touch domain/query-key code.
+- **Risk — package lifecycle**: the dependency is explicitly "experimental" in
+ the TanStack ecosystem; its API may change or be deprecated between minor
+ versions. `@tanstack/query-broadcast-client-experimental`,
+ `@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
+ the **exact same version** in `package.json` (no `^` range). All three
+ resolve to the identical `@tanstack/query-core` version at that pin, which
+ keeps the `QueryClient` type used by `setupQueryBroadcast` structurally
+ identical to the one constructed in `query-client.ts` — a caret range lets
+ npm resolve the broadcast client and React Query against two different
+ `query-core` versions independently, which breaks `QueryClient` type
+ identity (TS2322) even though both packages build fine in isolation. Bump
+ all three together and re-evaluate on every TanStack Query upgrade.
+- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
+ origin; it does not sync across different users/sessions or devices. This is
+ acceptable for the current requirement (single user, multiple tabs).
+- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
+ dependency from `package.json` and delete the call to `setupQueryBroadcast`
+ in `src/lib/query/query-client.ts` (and the `setup-query-broadcast.ts` module
+ itself). No other code depends on it, so rollback is a single, isolated
+ change with no data migration.
diff --git a/package-lock.json b/package-lock.json
index c732e572..bd4d8c90 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,8 +21,9 @@
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
- "@tanstack/react-query": "^5.101.2",
- "@tanstack/react-query-devtools": "^5.101.2",
+ "@tanstack/query-broadcast-client-experimental": "5.101.2",
+ "@tanstack/react-query": "5.101.2",
+ "@tanstack/react-query-devtools": "5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -2332,6 +2333,20 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
+ "node_modules/@tanstack/query-broadcast-client-experimental": {
+ "version": "5.101.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.101.2.tgz",
+ "integrity": "sha512-BFJp1HqDjUY3x51zzgfB2scGhg1nw2V8Y6+aoSA8TEWYh0oI5kYx1z+37fzlws2uaT7ObjCz1PXzzftE6xpmhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.101.2",
+ "broadcast-channel": "^7.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tanstack/query-core": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
@@ -3214,6 +3229,30 @@
"node": ">=8"
}
},
+ "node_modules/broadcast-channel": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.3.0.tgz",
+ "integrity": "sha512-UHPhLBQKfQ8OmMFMpmPfO5dRakyA1vsfiDGWTYNvChYol65tbuhivPEGgZZiuetorvExdvxaWiBy/ym1Ty08yA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "7.28.6",
+ "oblivious-set": "2.0.0",
+ "p-queue": "6.6.2",
+ "unload": "2.4.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/pubkey"
+ }
+ },
+ "node_modules/broadcast-channel/node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.6",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
@@ -5345,6 +5384,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/oblivious-set": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-2.0.0.tgz",
+ "integrity": "sha512-QOUH5Xrsced9fKXaQTjWoDGKeS/Or7E2jB0FN63N4mkAO4qJdB7WR7e6qWAOHM5nk25FJ8TGjhP7DH4l6vFVLg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
@@ -5393,6 +5441,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -5425,6 +5482,40 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-queue": {
+ "version": "6.6.2",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
+ "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^4.0.4",
+ "p-timeout": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-queue/node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/p-timeout": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+ "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+ "license": "MIT",
+ "dependencies": {
+ "p-finally": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6401,6 +6492,15 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/unload": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz",
+ "integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==",
+ "license": "Apache-2.0",
+ "funding": {
+ "url": "https://github.com/sponsors/pubkey"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
diff --git a/package.json b/package.json
index 3df2f4da..e3204f6b 100644
--- a/package.json
+++ b/package.json
@@ -54,8 +54,9 @@
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
- "@tanstack/react-query": "^5.101.2",
- "@tanstack/react-query-devtools": "^5.101.2",
+ "@tanstack/query-broadcast-client-experimental": "5.101.2",
+ "@tanstack/react-query": "5.101.2",
+ "@tanstack/react-query-devtools": "5.101.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json
index 401e1e70..18c4ae08 100644
--- a/scripts/governance-baseline.json
+++ b/scripts/governance-baseline.json
@@ -3,11 +3,6 @@
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
"maxFileLines": 500,
"godfileDebt": [
- {
- "path": "src/app/(protected)/workorders/[id].tsx",
- "maxLines": 797,
- "reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
- },
{
"path": "src/app/(protected)/vendors/index.tsx",
"maxLines": 612,
diff --git a/src/api/api.ts b/src/api/api.ts
index 19c29d81..d27d3365 100644
--- a/src/api/api.ts
+++ b/src/api/api.ts
@@ -5,7 +5,6 @@ import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client";
-import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
function clearSessionAndRedirectToLogin(): void {
@@ -48,7 +47,7 @@ export const api: KyInstance = ky.create({
beforeError: [({ error }) => normalizeApiRequestError(error)],
afterResponse: [
({ request, response }) => {
- if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) {
+ if (response.status !== 401 || isAuthLoginRequest(request)) {
return response;
}
diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx
index cfdcf7d7..945bd3b3 100644
--- a/src/app/(protected)/workorders/[id].tsx
+++ b/src/app/(protected)/workorders/[id].tsx
@@ -1,108 +1,110 @@
-import { useState } from "react";
import { useNavigate, useParams } from "react-router";
-import { Alert, Box, CircularProgress } from "@mui/material";
+import { Box } from "@mui/material";
+import { QueryErrorPanel } from "@/components/ui/query-error-panel";
+import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton";
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
-import { VendorEditorDialog } from "./_components/vendor-editor-dialog";
-import { useVendorEditor } from "./_components/use-vendor-editor";
-import { WorkOrderActivity } from "./_components/work-order-activity";
+import { WorkOrderHeaderSection } from "@/app/(protected)/workorders/_components/view/work-order-header-section";
+import { WorkOrderDetailsPanel } from "@/app/(protected)/workorders/_components/view/work-order-details-panel";
import {
- DispatchAccordion,
- WorkOrderDetailsSection,
- WorkOrderHeader,
- WorkOrderSummaryAccordions,
-} from "./_components/work-order-view-sections";
-import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
-import {
- useChangeWorkOrderAssignment,
- useChangeWorkOrderStatus,
-} from "@/domain/work-orders/use-cases/use-work-order-mutations";
-import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
+ WorkOrderDescriptionAccordion,
+ WorkOrderInfoAccordions,
+} from "@/app/(protected)/workorders/_components/view/work-order-info-accordions";
+import { WorkOrderDispatchesAccordion } from "@/app/(protected)/workorders/_components/view/work-order-dispatches-accordion";
+import { WorkOrderActivityPanel } from "@/app/(protected)/workorders/_components/view/work-order-activity-panel";
+import { WorkOrderVendorEditorDialog } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog";
+import { useWorkOrderViewController } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller";
export default function WorkOrderViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const workOrderId = id ?? "";
+ const controller = useWorkOrderViewController(workOrderId);
+ const { wo, isLoading, error, refetch, vendorEditor } = controller;
- const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
- const { data: users = [] } = useWorkOrderUsers();
- const statusMutation = useChangeWorkOrderStatus(workOrderId);
- const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
- const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() });
+ if (isLoading && !wo) {
+ return ;
+ }
- const [dispatchOpen, setDispatchOpen] = useState(false);
- const [dispatchDetailId, setDispatchDetailId] = useState(null);
-
- if (isLoading) {
+ if ((error || !wo) && !isLoading) {
return (
-
-
+
+ void refetch()}
+ />
);
}
- if (error || !wo) {
- return (
-
-
- {error instanceof Error ? error.message : "Work order not found."}
-
-
- );
+ if (!wo) {
+ return ;
}
return (
- statusMutation.mutate(status)}
- onDispatch={() => setDispatchOpen(true)}
+ statusAnchor={controller.statusAnchor}
+ onStatusAnchorChange={controller.setStatusAnchor}
+ onStatusChange={(status) => controller.statusMutation.mutate(status)}
+ onDispatch={() => controller.setDispatchOpen(true)}
onEdit={() => navigate(`/workorders/edit/${id}`)}
onBack={() => navigate("/workorders")}
/>
- assignmentMutation.mutate(payload)}
+ users={controller.users}
+ assignAnchor={controller.assignAnchor}
+ onAssignAnchorChange={controller.setAssignAnchor}
+ onAssign={(userId, userName) => controller.assignmentMutation.mutate({ userId, userName })}
/>
-
-
- void vendorEditor.open(event, vendorId, vendorName)
- }
- onViewDispatch={setDispatchDetailId}
+
+ {
+ event.stopPropagation();
+ void vendorEditor.openVendorEditor(vendorId, vendorName);
+ }}
/>
-
+
- {dispatchOpen && (
+ {controller.dispatchOpen && (
setDispatchOpen(false)}
+ onClose={() => controller.setDispatchOpen(false)}
onDispatched={() => void refetch()}
/>
)}
- {dispatchDetailId != null && (
+ {controller.dispatchDetailId != null && (
setDispatchDetailId(null)}
+ onClose={() => controller.setDispatchDetailId(null)}
onUpdated={() => void refetch()}
/>
)}
-
+ vendorEditor.setVendorEditor((current) => (current ? { ...current, ...patch } : current))
+ }
+ onSave={vendorEditor.saveVendorEditor}
/>
);
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx
index 86749598..4468592b 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx
@@ -28,6 +28,7 @@ export function CompDocDialogFooterDraftActions({
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }}
+ disabled={isUploading}
onClick={onMarkNotRequired}
>
Mark as not required
@@ -38,6 +39,7 @@ export function CompDocDialogFooterDraftActions({
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
+ disabled={isUploading}
onClick={onSaveDraft}
>
Save draft
@@ -47,7 +49,7 @@ export function CompDocDialogFooterDraftActions({
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
- disabled={!rowVersion}
+ disabled={!rowVersion || isUploading}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
@@ -60,6 +62,7 @@ export function CompDocDialogFooterDraftActions({
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
+ disabled={isUploading}
onClick={onGenerate}
>
Generate
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx
index ec9c3479..829cf976 100644
--- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx
@@ -1,7 +1,7 @@
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
-import type { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
@@ -14,14 +14,12 @@ type SlideOverInfoTabDisplayViewProps = {
source: WorkOrderTableRow;
isCancelled: boolean;
dispatcher?: WorkOrderUser;
- vendors: VendorDropdownItem[];
};
export function SlideOverInfoTabDisplayView({
source,
isCancelled,
dispatcher,
- vendors,
}: SlideOverInfoTabDisplayViewProps) {
const appt = parseApptTime(source.apptTime);
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
@@ -73,7 +71,7 @@ export function SlideOverInfoTabDisplayView({
)}
-
+
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx
index 389ff87e..6c66e829 100644
--- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx
@@ -1,45 +1,25 @@
-import { useState } from "react";
-import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
-import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverInfoTabVendorFieldProps = {
source: WorkOrderTableRow;
- vendors: VendorDropdownItem[];
};
-export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) {
- const [vendorDlg, setVendorDlg] = useState(false);
-
+/** Display-only vendor summary. Assignment edits belong in edit mode (VendorCell). */
+export function SlideOverInfoTabVendorField({ source }: SlideOverInfoTabVendorFieldProps) {
if (!source.tech && !source.company) {
return —;
}
return (
- <>
-
);
}
diff --git a/src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts b/src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts
new file mode 100644
index 00000000..47464dfe
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts
@@ -0,0 +1,159 @@
+import { useMemo, useState } from "react";
+import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
+import {
+ useTaskListTemplates,
+ useWorkOrderVendors,
+} from "@/domain/work-orders/use-cases/use-work-order-detail";
+import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
+import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order";
+import { guardDialogClose } from "@/lib/dialog-guard";
+
+export function useDispatchCreateController(
+ workOrder: WorkOrderDetail,
+ onClose: () => void,
+ onDispatched: () => void,
+) {
+ const [selectedVendorId, setSelectedVendorId] = useState