fix(work-orders): remove legacy completedDate EditWorkorder path

[recover] remove malicious eslint payload (was f4e6132b)
This commit is contained in:
Arthur Bassi 2026-08-10 17:12:14 -03:00
parent 2eb5359d82
commit 6fe1cd0f27
53 changed files with 2367 additions and 865 deletions

View file

@ -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.

104
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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,

View file

@ -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;
}

View file

@ -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 <DetailPageSkeleton variant="view" />;
}
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
if (isLoading) {
if ((error || !wo) && !isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
<Box className="p-4">
<QueryErrorPanel
message={error instanceof Error ? error.message : "Work order not found."}
onRetry={() => void refetch()}
/>
</Box>
);
}
if (error || !wo) {
return (
<Box className="p-4">
<Alert severity="error">
{error instanceof Error ? error.message : "Work order not found."}
</Alert>
</Box>
);
if (!wo) {
return <DetailPageSkeleton variant="view" />;
}
return (
<Box className="flex flex-col gap-4 p-4">
<WorkOrderHeader
<WorkOrderHeaderSection
wo={wo}
onSelectStatus={(status) => 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")}
/>
<WorkOrderDetailsSection
<WorkOrderDetailsPanel
wo={wo}
users={users}
onAssign={(payload) => assignmentMutation.mutate(payload)}
users={controller.users}
assignAnchor={controller.assignAnchor}
onAssignAnchorChange={controller.setAssignAnchor}
onAssign={(userId, userName) => controller.assignmentMutation.mutate({ userId, userName })}
/>
<WorkOrderSummaryAccordions wo={wo} />
<DispatchAccordion
wo={wo}
onEditVendor={(event, vendorId, vendorName) =>
void vendorEditor.open(event, vendorId, vendorName)
}
onViewDispatch={setDispatchDetailId}
<WorkOrderInfoAccordions wo={wo} />
<WorkOrderDispatchesAccordion
dispatches={wo.dispatches}
onSelectDispatch={controller.setDispatchDetailId}
onEditVendor={(event, vendorId, vendorName) => {
event.stopPropagation();
void vendorEditor.openVendorEditor(vendorId, vendorName);
}}
/>
<WorkOrderActivity
workOrderId={workOrderId}
comments={wo.comments ?? []}
auditLog={wo.auditLog ?? []}
<WorkOrderDescriptionAccordion description={wo.description} />
<WorkOrderActivityPanel
activeTab={controller.activeTab}
onTabChange={controller.setActiveTab}
customerComments={controller.customerComments}
internalComments={controller.internalComments}
auditLog={controller.auditLog}
commentText={controller.commentText}
onCommentTextChange={controller.setCommentText}
isSubmittingComment={controller.commentMutation.isPending}
onSubmitComment={controller.handleCommentSubmit}
/>
{dispatchOpen && (
{controller.dispatchOpen && (
<DispatchCreateModal
workOrder={wo}
onClose={() => setDispatchOpen(false)}
onClose={() => controller.setDispatchOpen(false)}
onDispatched={() => void refetch()}
/>
)}
{dispatchDetailId != null && (
{controller.dispatchDetailId != null && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
dispatchId={controller.dispatchDetailId}
workOrderId={workOrderId}
onClose={() => setDispatchDetailId(null)}
onClose={() => controller.setDispatchDetailId(null)}
onUpdated={() => void refetch()}
/>
)}
<VendorEditorDialog
editor={vendorEditor.editor}
loading={vendorEditor.loading}
error={vendorEditor.error}
isPending={vendorEditor.isPending}
phoneInvalid={vendorEditor.phoneInvalid}
isValid={vendorEditor.isValid}
onClose={vendorEditor.close}
onSave={vendorEditor.save}
onFieldChange={vendorEditor.updateField}
<WorkOrderVendorEditorDialog
vendorEditor={vendorEditor.vendorEditor}
isLoading={vendorEditor.vendorEditorLoading}
isSaving={vendorEditor.vendorMutation.isPending}
error={vendorEditor.vendorEditorError}
onClose={vendorEditor.closeVendorEditor}
onChange={(patch) =>
vendorEditor.setVendorEditor((current) => (current ? { ...current, ...patch } : current))
}
onSave={vendorEditor.saveVendorEditor}
/>
</Box>
);

View file

@ -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}
>
<Ban size={14} /> 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 size={14} /> 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}
>
<Sparkles size={14} /> Generate

View file

@ -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({
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<SlideOverInfoTabVendorField source={source} vendors={vendors} />
<SlideOverInfoTabVendorField source={source} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">
<SlideOverInfoTabStatusField source={source} />

View file

@ -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 <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}
return (
<>
<button
type="button"
onClick={() => setVendorDlg(true)}
className="text-left transition-opacity hover:opacity-80"
>
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
{source.tech || source.company}
<div>
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
{source.tech || source.company}
</div>
{Boolean(source.tech && source.company) && (
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
{source.company}
</div>
{Boolean(source.tech && source.company) && (
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
{source.company}
</div>
)}
</button>
<VendorDialog
open={vendorDlg}
onOpenChange={setVendorDlg}
company={source.company}
tech={source.tech}
techPhone={source.techPhone}
vendors={vendors}
onSave={() => setVendorDlg(false)}
/>
</>
)}
</div>
);
}

View file

@ -58,7 +58,6 @@ export function SlideOverInfoTab({
source={source}
isCancelled={isCancelled}
dispatcher={dispatcher}
vendors={vendors}
/>
);
}

View file

@ -1,377 +0,0 @@
import { useMemo, useState } from "react";
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
MenuItem,
Radio,
Stack,
TextField,
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
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 { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
interface SelectedWorkOrder {
id: string | number;
number: string;
title: string;
}
interface DispatchCreateModalProps {
workOrder: WorkOrderDetail;
onClose: () => void;
onDispatched: () => void;
}
export function DispatchCreateModal({
workOrder,
onClose,
onDispatched,
}: DispatchCreateModalProps) {
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
const [scheduledDate, setScheduledDate] = useState("");
const [description, setDescription] = useState(workOrder.description || "");
const [vendorSearch, setVendorSearch] = useState("");
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [customItems, setCustomItems] = useState<string[]>([]);
const [newCustomItem, setNewCustomItem] = useState("");
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([
{
id: workOrder.id,
number: workOrder.internalWONumber,
title: workOrder.title,
},
]);
const [woSearch, setWoSearch] = useState("");
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
workOrder.trade,
workOrder.locationZip,
);
const { data: taskTemplates = [] } = useTaskListTemplates();
const dispatchMutation = useDispatchToVendor(workOrder.id);
const { data: siteWorkOrdersData } = useWorkOrdersList({
search: "",
page: 1,
pageSize: 500,
sortBy: "number",
sortDir: "asc",
locationId: workOrder.locationId,
});
const siteWorkOrders = useMemo(
() =>
(siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)),
[siteWorkOrdersData?.items, workOrder.id],
);
const filteredVendors = useMemo(
() =>
vendors.filter(
(vendor) =>
vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
),
[vendors, vendorSearch],
);
const filteredSiteWOs = useMemo(
() =>
siteWorkOrders
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
.filter(
(item) =>
!woSearch ||
item.woNumber.includes(woSearch) ||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
)
.slice(0, 10),
[siteWorkOrders, selectedWOs, woSearch],
);
const handleSubmit = () => {
if (!selectedVendorId) {
return;
}
dispatchMutation.mutate(
{
workOrderIds: selectedWOs.map((wo) => wo.id),
vendorIds: [selectedVendorId],
nteAmount: nteAmount ? parseFloat(nteAmount) : null,
scheduledDate: scheduledDate || null,
description,
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
customChecklistItems: customItems.length > 0 ? customItems : null,
},
{
onSuccess: () => {
onDispatched();
onClose();
},
},
);
};
return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2">
Dispatch{" "}
{selectedWOs.length > 1
? `${selectedWOs.length} Work Orders`
: `Work Order ${workOrder.internalWONumber}`}
<IconButton aria-label="Close" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<Box>
<Typography variant="subtitle2" className="mb-2">
Work Orders ({selectedWOs.length})
</Typography>
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{selectedWOs.map((wo) => (
<Chip
key={String(wo.id)}
label={`${wo.number} — ${wo.title}`}
onDelete={
String(wo.id) !== String(workOrder.id)
? () => setSelectedWOs((prev) => prev.filter((item) => item.id !== wo.id))
: undefined
}
/>
))}
</Stack>
{siteWorkOrders.length > 0 && (
<Box className="mt-3">
<TextField
size="small"
value={woSearch}
onChange={(event) => setWoSearch(event.target.value)}
placeholder="Search to filter..."
fullWidth
className="mb-2"
/>
<PaperList>
{filteredSiteWOs.map((item) => (
<Box
key={String(item.id)}
className="cursor-pointer rounded px-2 py-1 hover:bg-muted/50"
onClick={() => {
setSelectedWOs((prev) => [
...prev,
{ id: item.id, number: item.woNumber, title: item.title },
]);
setWoSearch("");
}}
>
<Typography variant="body2">
<strong>{item.woNumber}</strong> — {item.title}
</Typography>
</Box>
))}
</PaperList>
</Box>
)}
</Box>
<Box>
<Typography variant="subtitle2" className="mb-2">
Select Vendor
</Typography>
<TextField
size="small"
value={vendorSearch}
onChange={(event) => setVendorSearch(event.target.value)}
placeholder="Search vendors..."
fullWidth
className="mb-2"
/>
{isLoadingVendors ? (
<Typography
sx={{
color: "text.secondary",
}}
>
Loading vendors...
</Typography>
) : filteredVendors.length === 0 ? (
<Typography
sx={{
color: "text.secondary",
}}
>
No vendors found
</Typography>
) : (
<Stack spacing={1} className="max-h-60 overflow-y-auto">
{filteredVendors.map((vendor) => (
<Box
key={String(vendor.id)}
className={`flex cursor-pointer items-start gap-2 rounded border p-2 ${
selectedVendorId === vendor.id
? "border-primary bg-primary/10"
: "border-border"
}`}
onClick={() =>
setSelectedVendorId((current) => (current === vendor.id ? null : vendor.id))
}
>
<Radio checked={selectedVendorId === vendor.id} readOnly size="small" />
<Box className="flex-1">
<Typography variant="body2" className="font-semibold">
{vendor.companyName}
</Typography>
{Boolean(vendor.tradeSpecialties) && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{vendor.tradeSpecialties}
</Typography>
)}
{Boolean(vendor.address) && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{vendor.address}
</Typography>
)}
</Box>
{vendor.distanceMiles != null && (
<Typography variant="caption" className="whitespace-nowrap">
{vendor.distanceMiles} miles
</Typography>
)}
</Box>
))}
</Stack>
)}
</Box>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="NTE Amount"
type="number"
value={nteAmount}
onChange={(event) => setNteAmount(event.target.value)}
fullWidth
slotProps={{
htmlInput: { step: "0.01", min: 0 },
}}
/>
<TextField
label="Vendor Scheduled Date"
type="date"
value={scheduledDate}
onChange={(event) => setScheduledDate(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
<TextField
label="Task List Template"
select
value={selectedTemplateId}
onChange={(event) => setSelectedTemplateId(event.target.value)}
fullWidth
>
<MenuItem value="">No checklist template</MenuItem>
{taskTemplates.map((template) => (
<MenuItem key={String(template.id)} value={String(template.id)}>
{template.name} ({template.items?.length ?? 0} items)
</MenuItem>
))}
</TextField>
{customItems.length > 0 && (
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{customItems.map((item, index) => (
<Chip
key={`${item}-${index}`}
label={item}
onDelete={() => setCustomItems((prev) => prev.filter((_, i) => i !== index))}
/>
))}
</Stack>
)}
<TextField
value={newCustomItem}
onChange={(event) => setNewCustomItem(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newCustomItem.trim()) {
event.preventDefault();
setCustomItems((prev) => [...prev, newCustomItem.trim()]);
setNewCustomItem("");
}
}}
placeholder="Add custom checklist item..."
fullWidth
size="small"
/>
<TextField
label="Description"
multiline
rows={4}
value={description}
onChange={(event) => setDescription(event.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={dispatchMutation.isPending || !selectedVendorId}
>
{dispatchMutation.isPending ? "Dispatching..." : "Dispatch"}
</Button>
</DialogActions>
</Dialog>
);
}
function PaperList({ children }: { children: React.ReactNode }) {
return (
<Box className="max-h-40 overflow-y-auto rounded border border-border p-1">{children}</Box>
);
}

View file

@ -0,0 +1,84 @@
import { Chip, MenuItem, Skeleton, Stack, TextField } from "@mui/material";
import type { TaskListTemplate } from "@/domain/work-orders/types/work-order";
interface DispatchCreateChecklistFieldsProps {
taskTemplates: TaskListTemplate[];
isLoadingTemplates: boolean;
selectedTemplateId: string;
onTemplateChange: (id: string) => void;
isPending: boolean;
customItems: string[];
onRemoveCustomItem: (index: number) => void;
newCustomItem: string;
onNewCustomItemChange: (value: string) => void;
onAddCustomItem: () => void;
}
export function DispatchCreateChecklistFields({
taskTemplates,
isLoadingTemplates,
selectedTemplateId,
onTemplateChange,
isPending,
customItems,
onRemoveCustomItem,
newCustomItem,
onNewCustomItemChange,
onAddCustomItem,
}: DispatchCreateChecklistFieldsProps) {
return (
<>
{isLoadingTemplates ? (
<Skeleton variant="rounded" height={56} animation="wave" aria-label="Loading templates" />
) : (
<TextField
label="Task List Template"
select
value={selectedTemplateId}
onChange={(event) => onTemplateChange(event.target.value)}
fullWidth
disabled={isPending}
>
<MenuItem value="">No checklist template</MenuItem>
{taskTemplates.map((template) => (
<MenuItem key={String(template.id)} value={String(template.id)}>
{template.name} ({template.items?.length ?? 0} items)
</MenuItem>
))}
</TextField>
)}
{customItems.length > 0 && (
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{customItems.map((item, index) => (
<Chip
key={`${item}-${index}`}
label={item}
onDelete={() => onRemoveCustomItem(index)}
/>
))}
</Stack>
)}
<TextField
value={newCustomItem}
onChange={(event) => onNewCustomItemChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newCustomItem.trim()) {
event.preventDefault();
onAddCustomItem();
}
}}
placeholder="Add custom checklist item..."
fullWidth
size="small"
/>
</>
);
}

View file

@ -0,0 +1,38 @@
import { Stack, TextField } from "@mui/material";
interface DispatchCreateDetailsFieldsProps {
nteAmount: string;
onNteAmountChange: (value: string) => void;
scheduledDate: string;
onScheduledDateChange: (value: string) => void;
}
export function DispatchCreateDetailsFields({
nteAmount,
onNteAmountChange,
scheduledDate,
onScheduledDateChange,
}: DispatchCreateDetailsFieldsProps) {
return (
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="NTE Amount"
type="number"
value={nteAmount}
onChange={(event) => onNteAmountChange(event.target.value)}
fullWidth
slotProps={{
htmlInput: { step: "0.01", min: 0 },
}}
/>
<TextField
label="Vendor Scheduled Date"
type="date"
value={scheduledDate}
onChange={(event) => onScheduledDateChange(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
);
}

View file

@ -0,0 +1,110 @@
import { Box, Radio, Skeleton, Stack, TextField, Typography } from "@mui/material";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
interface DispatchCreateVendorSectionProps {
vendors: VendorDropdownItem[];
isLoading: boolean;
vendorSearch: string;
selectedVendorId: string | number | null;
onSearchChange: (value: string) => void;
onSelect: (vendorId: string | number) => void;
}
function VendorLine({ label }: { label: string }) {
return (
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{label}
</Typography>
);
}
function VendorOption({
vendor,
isSelected,
onSelect,
}: {
vendor: VendorDropdownItem;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<Box
className={`flex cursor-pointer items-start gap-2 rounded border p-2 ${
isSelected ? "border-primary bg-primary/10" : "border-border"
}`}
onClick={onSelect}
>
<Radio checked={isSelected} readOnly size="small" />
<Box className="flex-1">
<Typography variant="body2" className="font-semibold">
{vendor.companyName}
</Typography>
{Boolean(vendor.contactName) && <VendorLine label={vendor.contactName} />}
{Boolean(vendor.tradeSpecialties) && <VendorLine label={vendor.tradeSpecialties} />}
{Boolean(vendor.address) && <VendorLine label={vendor.address} />}
</Box>
{vendor.distanceMiles != null && (
<Typography variant="caption" className="whitespace-nowrap">
{vendor.distanceMiles} miles
</Typography>
)}
</Box>
);
}
export function DispatchCreateVendorSection({
vendors,
isLoading,
vendorSearch,
selectedVendorId,
onSearchChange,
onSelect,
}: DispatchCreateVendorSectionProps) {
return (
<Box>
<Typography variant="subtitle2" className="mb-2">
Select Vendor
</Typography>
<TextField
size="small"
value={vendorSearch}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search vendors..."
fullWidth
className="mb-2"
/>
{isLoading ? (
<Stack spacing={1} aria-busy="true" role="status" aria-label="Loading vendors">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} variant="rounded" height={56} animation="wave" />
))}
</Stack>
) : vendors.length === 0 ? (
<Typography
sx={{
color: "text.secondary",
}}
>
No vendors found
</Typography>
) : (
<Stack spacing={1} className="max-h-60 overflow-y-auto">
{vendors.map((vendor) => (
<VendorOption
key={String(vendor.id)}
vendor={vendor}
isSelected={selectedVendorId === vendor.id}
onSelect={() => onSelect(vendor.id)}
/>
))}
</Stack>
)}
</Box>
);
}

View file

@ -0,0 +1,73 @@
import { Box, Chip, Stack, TextField, Typography } from "@mui/material";
import type { SelectedWorkOrderRef } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
interface DispatchCreateWorkOrdersSectionProps {
workOrderId: string | number;
selectedWOs: SelectedWorkOrderRef[];
siteWorkOrders: WorkOrderTableRow[];
filteredSiteWOs: WorkOrderTableRow[];
woSearch: string;
onWoSearchChange: (value: string) => void;
onAddWO: (item: WorkOrderTableRow) => void;
onRemoveWO: (id: string | number) => void;
}
export function DispatchCreateWorkOrdersSection({
workOrderId,
selectedWOs,
siteWorkOrders,
filteredSiteWOs,
woSearch,
onWoSearchChange,
onAddWO,
onRemoveWO,
}: DispatchCreateWorkOrdersSectionProps) {
return (
<Box>
<Typography variant="subtitle2" className="mb-2">
Work Orders ({selectedWOs.length})
</Typography>
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
{selectedWOs.map((wo) => (
<Chip
key={String(wo.id)}
label={`${wo.number} — ${wo.title}`}
onDelete={String(wo.id) !== String(workOrderId) ? () => onRemoveWO(wo.id) : undefined}
/>
))}
</Stack>
{siteWorkOrders.length > 0 && (
<Box className="mt-3">
<TextField
size="small"
value={woSearch}
onChange={(event) => onWoSearchChange(event.target.value)}
placeholder="Search to filter..."
fullWidth
className="mb-2"
/>
<Box className="max-h-40 overflow-y-auto rounded border border-border p-1">
{filteredSiteWOs.map((item) => (
<Box
key={String(item.id)}
className="cursor-pointer rounded px-2 py-1 hover:bg-muted/50"
onClick={() => onAddWO(item)}
>
<Typography variant="body2">
<strong>{item.woNumber}</strong> — {item.title}
</Typography>
</Box>
))}
</Box>
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,114 @@
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { Button } from "@/components/ui/button";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { useDispatchCreateController } from "@/app/(protected)/workorders/_hooks/use-dispatch-create-controller";
import { DispatchCreateWorkOrdersSection } from "./dispatch-create-work-orders-section";
import { DispatchCreateVendorSection } from "./dispatch-create-vendor-section";
import { DispatchCreateChecklistFields } from "./dispatch-create-checklist-fields";
import { DispatchCreateDetailsFields } from "./dispatch-create-details-fields";
interface DispatchCreateModalProps {
workOrder: WorkOrderDetail;
onClose: () => void;
onDispatched: () => void;
}
export function DispatchCreateModal({
workOrder,
onClose,
onDispatched,
}: DispatchCreateModalProps) {
const controller = useDispatchCreateController(workOrder, onClose, onDispatched);
return (
<Dialog open onClose={controller.handleClose} maxWidth="md" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2">
Dispatch{" "}
{controller.selectedWOs.length > 1
? `${controller.selectedWOs.length} Work Orders`
: `Work Order ${workOrder.internalWONumber}`}
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
disabled={controller.isPending}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<DispatchCreateWorkOrdersSection
workOrderId={workOrder.id}
selectedWOs={controller.selectedWOs}
siteWorkOrders={controller.siteWorkOrders}
filteredSiteWOs={controller.filteredSiteWOs}
woSearch={controller.woSearch}
onWoSearchChange={controller.setWoSearch}
onAddWO={controller.addSelectedWO}
onRemoveWO={controller.removeSelectedWO}
/>
<DispatchCreateVendorSection
vendors={controller.vendors}
isLoading={controller.isLoadingVendors}
vendorSearch={controller.vendorSearch}
selectedVendorId={controller.selectedVendorId}
onSearchChange={controller.setVendorSearch}
onSelect={(vendorId) =>
controller.setSelectedVendorId((current) => (current === vendorId ? null : vendorId))
}
/>
<DispatchCreateDetailsFields
nteAmount={controller.nteAmount}
onNteAmountChange={controller.setNteAmount}
scheduledDate={controller.scheduledDate}
onScheduledDateChange={controller.setScheduledDate}
/>
<DispatchCreateChecklistFields
taskTemplates={controller.taskTemplates}
isLoadingTemplates={controller.isLoadingTemplates}
selectedTemplateId={controller.selectedTemplateId}
onTemplateChange={controller.setSelectedTemplateId}
isPending={controller.isPending}
customItems={controller.customItems}
onRemoveCustomItem={controller.removeCustomItem}
newCustomItem={controller.newCustomItem}
onNewCustomItemChange={controller.setNewCustomItem}
onAddCustomItem={controller.addCustomItem}
/>
<TextField
label="Description"
multiline
rows={4}
value={controller.description}
onChange={(event) => controller.setDescription(event.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button variant="outline" onClick={onClose} disabled={controller.isPending}>
Cancel
</Button>
<Button
onClick={controller.handleSubmit}
disabled={!controller.selectedVendorId}
loading={controller.isPending}
loadingText="Dispatching…"
>
Dispatch
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -6,6 +6,7 @@ type DispatchChecklistSectionProps = {
items: DispatchChecklistItem[];
isLocked: boolean;
isUpdating: boolean;
isAdding?: boolean;
newItem: string;
onNewItemChange: (value: string) => void;
onToggleItem: (itemId: string | number, isCompleted: boolean) => void;
@ -16,6 +17,7 @@ export function DispatchChecklistSection({
items,
isLocked,
isUpdating,
isAdding = false,
newItem,
onNewItemChange,
onToggleItem,
@ -58,14 +60,16 @@ export function DispatchChecklistSection({
value={newItem}
onChange={(event) => onNewItemChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newItem.trim()) {
if (event.key === "Enter" && newItem.trim() && !isAdding) {
event.preventDefault();
onAddItem(newItem.trim());
}
}}
placeholder="Add checklist item..."
placeholder={isAdding ? "Adding…" : "Add checklist item..."}
disabled={isAdding}
fullWidth
className="mt-2"
slotProps={{ htmlInput: { "aria-busy": isAdding || undefined } }}
/>
)}
</Box>

View file

@ -0,0 +1,100 @@
import { DialogContent, Skeleton } from "@mui/material";
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
import type { DispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller";
import { DispatchChecklistSection } from "./dispatch-checklist-section";
import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
import { DispatchSignoffsSection } from "./dispatch-signoffs-section";
import { DispatchUpliftsSection } from "./dispatch-uplifts-section";
import { DispatchVendorCommunication } from "./dispatch-vendor-communication";
import { DispatchVerifyFooter } from "./dispatch-verify-footer";
interface DispatchDetailBodyProps {
dispatch: DispatchDetail;
controller: DispatchDetailController;
}
export function DispatchDetailBody({ dispatch, controller }: DispatchDetailBodyProps) {
return (
<DialogContent className="flex flex-col gap-6">
<DispatchDetailsGrid>
{controller.editing ? (
<DispatchEditForm
form={controller.form}
onChange={controller.setForm}
onSave={controller.handleSave}
onCancel={() => controller.setEditing(false)}
isSaving={controller.updateDispatchMutation.isPending}
/>
) : (
<DispatchDetailsView
dispatch={dispatch}
isLocked={controller.isLocked}
onEdit={controller.beginEditing}
onCancelDispatch={() => controller.handleCancelDispatch(dispatch)}
/>
)}
</DispatchDetailsGrid>
{(dispatch.checklistItems.length > 0 || !controller.isLocked) && (
<DispatchChecklistSection
items={dispatch.checklistItems}
isLocked={controller.isLocked}
isUpdating={controller.updateChecklistMutation.isPending}
isAdding={controller.addChecklistMutation.isPending}
newItem={controller.newChecklistItem}
onNewItemChange={controller.setNewChecklistItem}
onToggleItem={(itemId, isCompleted) =>
controller.updateChecklistMutation.mutate({
itemId,
isCompleted,
completedBy: controller.userName,
})
}
onAddItem={(text) =>
controller.addChecklistMutation.mutate(text, {
onSuccess: () => controller.setNewChecklistItem(""),
})
}
/>
)}
<DispatchSignoffsSection
signoffs={dispatch.signoffs}
isLocked={controller.isLocked}
isSigningOff={controller.addSignoffMutation.isPending}
onCollectSignature={controller.setSigCaptureType}
/>
{controller.isLoadingUplifts ? (
<Skeleton variant="rounded" height={80} animation="wave" aria-label="Loading uplifts" />
) : (
<DispatchUpliftsSection
uplifts={controller.uplifts}
isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
/>
)}
<DispatchVendorCommunication
comments={dispatch.comments}
commentText={controller.commentText}
sendEmail={controller.sendEmail}
isLocked={controller.isLocked}
isPending={controller.addCommentMutation.isPending}
onCommentTextChange={controller.setCommentText}
onSendEmailChange={controller.setSendEmail}
onSubmit={controller.handleComment}
/>
<DispatchVerifyFooter
verifiedAt={dispatch.verifiedAt}
verifiedBy={dispatch.verifiedBy}
canVerify={controller.verifyState.canVerify}
missing={controller.verifyState.missing}
isPending={controller.verifyMutation.isPending}
onVerify={() => controller.verifyMutation.mutate(undefined)}
/>
</DialogContent>
);
}

View file

@ -1,5 +1,6 @@
import { Box, Button, MenuItem, Stack, TextField } from "@mui/material";
import { Box, MenuItem, Stack, TextField } from "@mui/material";
import { DetailField } from "@/components/common/detail-field";
import { Button } from "@/components/ui/button";
import {
DISPATCH_STATUS_OPTIONS,
type DispatchDetail,
@ -84,10 +85,12 @@ export function DispatchEditForm({
onChange={(event) => onChange({ ...form, description: event.target.value })}
/>
<Stack direction="row" spacing={1} className="sm:col-span-2">
<Button variant="contained" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
<Button onClick={onSave} loading={isSaving} loadingText="Saving…">
Save
</Button>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
Cancel
</Button>
<Button onClick={onCancel}>Cancel</Button>
</Stack>
</>
);
@ -138,12 +141,10 @@ export function DispatchDetailsView({
)}
{!isLocked && (
<Stack direction="row" spacing={1} className="sm:col-span-2">
<Button color="error" variant="outlined" onClick={onCancelDispatch}>
<Button variant="destructive" onClick={onCancelDispatch}>
Cancel Dispatch
</Button>
<Button variant="contained" onClick={onEdit}>
Edit
</Button>
<Button onClick={onEdit}>Edit</Button>
</Stack>
)}
</>

View file

@ -5,12 +5,14 @@ import { formatDateTime } from "@/lib/time-utils";
type DispatchSignoffsSectionProps = {
signoffs: DispatchSignoff[];
isLocked: boolean;
isSigningOff?: boolean;
onCollectSignature: (type: "Customer" | "Vendor") => void;
};
export function DispatchSignoffsSection({
signoffs,
isLocked,
isSigningOff = false,
onCollectSignature,
}: DispatchSignoffsSectionProps) {
return (
@ -49,9 +51,9 @@ export function DispatchSignoffsSection({
variant="outlined"
className="mt-2"
onClick={() => onCollectSignature(type)}
disabled={isLocked}
disabled={isLocked || isSigningOff}
>
{isLocked ? "Not signed" : "Collect Signature"}
{isLocked ? "Not signed" : isSigningOff ? "Saving…" : "Collect Signature"}
</Button>
)}
</Box>

View file

@ -1,6 +1,7 @@
import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material";
import { Box, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material";
import { CommentTimeline } from "@/components/common/comment-timeline";
import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import { Button } from "@/components/ui/button";
import type { DispatchComment } from "@/domain/work-orders/types/work-order";
type DispatchVendorCommunicationProps = {
@ -61,10 +62,11 @@ export function DispatchVendorCommunication({
/>
<Button
type="submit"
variant="contained"
disabled={isLocked || isPending || !commentText.trim()}
disabled={isLocked || !commentText.trim()}
loading={isPending}
loadingText="Sending…"
>
{isPending ? "Sending..." : "Send"}
Send
</Button>
</Stack>
</Box>

View file

@ -1,4 +1,5 @@
import { Alert, Button, Stack, Typography } from "@mui/material";
import { Alert, Stack, Typography } from "@mui/material";
import { Button } from "@/components/ui/button";
import { formatDateTime } from "@/lib/time-utils";
type DispatchVerifyFooterProps = {
@ -34,10 +35,15 @@ export function DispatchVerifyFooter({
</Typography>
)}
<Button
variant="contained"
color="success"
disabled={!canVerify || isPending}
disabled={!canVerify}
loading={isPending}
loadingText="Verifying…"
onClick={onVerify}
sx={{
backgroundColor: "success.main",
color: "#fff",
"&:hover": { backgroundColor: "success.dark" },
}}
>
Verify & Close
</Button>

View file

@ -1,13 +1,10 @@
import { CircularProgress, Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Dialog, DialogTitle } from "@mui/material";
import { SignatureCapture } from "@/components/common/signature-capture";
import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller";
import { DispatchChecklistSection } from "./dispatch-checklist-section";
import { DispatchDetailSkeleton } from "@/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton";
import { guardDialogClose } from "@/lib/dialog-guard";
import { DispatchDetailBody } from "./dispatch-detail-body";
import { DispatchDetailHeader } from "./dispatch-detail-header";
import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
import { DispatchSignoffsSection } from "./dispatch-signoffs-section";
import { DispatchUpliftsSection } from "./dispatch-uplifts-section";
import { DispatchVendorCommunication } from "./dispatch-vendor-communication";
import { DispatchVerifyFooter } from "./dispatch-verify-footer";
import { UpliftDecisionDialog } from "./uplift-decision-dialog";
interface DispatchDetailModalProps {
@ -23,13 +20,12 @@ export function DispatchDetailModal({
onClose,
}: DispatchDetailModalProps) {
const controller = useDispatchDetailController(dispatchId, workOrderId);
const handleClose = guardDialogClose(controller.isMutating, onClose);
if (controller.isLoading) {
if (controller.isLoading && !controller.dispatch) {
return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogContent className="flex justify-center py-12">
<CircularProgress />
</DialogContent>
<DispatchDetailSkeleton />
</Dialog>
);
}
@ -42,91 +38,23 @@ export function DispatchDetailModal({
return (
<>
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
<Dialog open onClose={handleClose} maxWidth="md" fullWidth scroll="paper">
<DialogTitle>
<DispatchDetailHeader dispatch={dispatch} onClose={onClose} />
<DispatchDetailHeader
dispatch={dispatch}
onClose={() => {
if (!controller.isMutating) onClose();
}}
/>
</DialogTitle>
<DialogContent className="flex flex-col gap-6">
<DispatchDetailsGrid>
{controller.editing ? (
<DispatchEditForm
form={controller.form}
onChange={controller.setForm}
onSave={controller.handleSave}
onCancel={() => controller.setEditing(false)}
isSaving={controller.updateDispatchMutation.isPending}
/>
) : (
<DispatchDetailsView
dispatch={dispatch}
isLocked={controller.isLocked}
onEdit={controller.beginEditing}
onCancelDispatch={() => controller.handleCancelDispatch(dispatch)}
/>
)}
</DispatchDetailsGrid>
{(dispatch.checklistItems.length > 0 || !controller.isLocked) && (
<DispatchChecklistSection
items={dispatch.checklistItems}
isLocked={controller.isLocked}
isUpdating={controller.updateChecklistMutation.isPending}
newItem={controller.newChecklistItem}
onNewItemChange={controller.setNewChecklistItem}
onToggleItem={(itemId, isCompleted) =>
controller.updateChecklistMutation.mutate({
itemId,
isCompleted,
completedBy: controller.userName,
})
}
onAddItem={(text) =>
controller.addChecklistMutation.mutate(text, {
onSuccess: () => controller.setNewChecklistItem(""),
})
}
/>
)}
<DispatchSignoffsSection
signoffs={dispatch.signoffs}
isLocked={controller.isLocked}
onCollectSignature={controller.setSigCaptureType}
/>
<DispatchUpliftsSection
uplifts={controller.uplifts}
isLocked={controller.isLocked}
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
/>
<DispatchVendorCommunication
comments={dispatch.comments}
commentText={controller.commentText}
sendEmail={controller.sendEmail}
isLocked={controller.isLocked}
isPending={controller.addCommentMutation.isPending}
onCommentTextChange={controller.setCommentText}
onSendEmailChange={controller.setSendEmail}
onSubmit={controller.handleComment}
/>
<DispatchVerifyFooter
verifiedAt={dispatch.verifiedAt}
verifiedBy={dispatch.verifiedBy}
canVerify={controller.verifyState.canVerify}
missing={controller.verifyState.missing}
isPending={controller.verifyMutation.isPending}
onVerify={() => controller.verifyMutation.mutate(undefined)}
/>
</DialogContent>
<DispatchDetailBody dispatch={dispatch} controller={controller} />
</Dialog>
{controller.sigCaptureType != null && (
<SignatureCapture
title={`${controller.sigCaptureType} Sign-off`}
isPending={controller.addSignoffMutation.isPending}
onClose={() => controller.setSigCaptureType(null)}
onSave={(signature, method, name) => {
controller.addSignoffMutation.mutate(
@ -148,7 +76,14 @@ export function DispatchDetailModal({
type={controller.upliftAction.type}
requestedNTE={controller.upliftAction.uplift.requestedNTE}
currentNTE={controller.upliftAction.uplift.currentNTE}
onClose={() => controller.setUpliftAction(null)}
onClose={() => {
if (
!controller.approveUpliftMutation.isPending &&
!controller.denyUpliftMutation.isPending
) {
controller.setUpliftAction(null);
}
}}
onConfirm={controller.handleUpliftConfirm}
isPending={
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending

View file

@ -1,6 +1,5 @@
import { useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
@ -8,6 +7,8 @@ import {
TextField,
Typography,
} from "@mui/material";
import { Button } from "@/components/ui/button";
import { guardDialogClose } from "@/lib/dialog-guard";
type UpliftDecisionDialogProps = {
open: boolean;
@ -31,17 +32,17 @@ export function UpliftDecisionDialog({
const [note, setNote] = useState("");
const handleClose = () => {
if (isPending) return;
setNote("");
onClose();
};
const handleConfirm = () => {
onConfirm(note.trim());
setNote("");
};
return (
<Dialog open={open} onClose={handleClose}>
<Dialog open={open} onClose={guardDialogClose(isPending, handleClose)}>
<DialogTitle>
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
</DialogTitle>
@ -60,14 +61,18 @@ export function UpliftDecisionDialog({
label={type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note}
onChange={(event) => setNote(event.target.value)}
disabled={isPending}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button variant="outline" onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<Button
variant="contained"
color={type === "approve" ? "success" : "error"}
disabled={type === "deny" ? !note.trim() || isPending : isPending}
variant={type === "approve" ? "default" : "destructive"}
disabled={type === "deny" ? !note.trim() : false}
loading={isPending}
loadingText="Confirming…"
onClick={handleConfirm}
>
Confirm

View file

@ -0,0 +1,73 @@
import { Box, Button, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
import {
AuditTimeline,
CommentTimeline,
} from "@/app/(protected)/workorders/_components/view/work-order-timelines";
import type { WorkOrderActivityTab } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller";
interface WorkOrderActivityPanelProps {
activeTab: WorkOrderActivityTab;
onTabChange: (tab: WorkOrderActivityTab) => void;
customerComments: WorkOrderComment[];
internalComments: WorkOrderComment[];
auditLog: WorkOrderAuditEntry[];
commentText: string;
onCommentTextChange: (text: string) => void;
isSubmittingComment: boolean;
onSubmitComment: (event: React.FormEvent) => void;
}
export function WorkOrderActivityPanel({
activeTab,
onTabChange,
customerComments,
internalComments,
auditLog,
commentText,
onCommentTextChange,
isSubmittingComment,
onSubmitComment,
}: WorkOrderActivityPanelProps) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Activity
</Typography>
<Tabs
value={activeTab}
onChange={(_event, value: WorkOrderActivityTab) => onTabChange(value)}
className="mb-3"
>
<Tab value="customer" label={`Customer (${customerComments.length})`} />
<Tab value="internal" label={`Internal (${internalComments.length})`} />
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs>
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
{activeTab === "internal" && (
<Box component="form" onSubmit={onSubmitComment} className="mt-4 flex flex-col gap-2">
<TextField
multiline
rows={2}
value={commentText}
onChange={(event) => onCommentTextChange(event.target.value)}
placeholder="Type a comment..."
disabled={isSubmittingComment}
/>
<Button
type="submit"
variant="contained"
className="self-end"
disabled={isSubmittingComment || !commentText.trim()}
>
{isSubmittingComment ? "Sending..." : "Send"}
</Button>
</Box>
)}
</Paper>
);
}

View file

@ -0,0 +1,76 @@
import { Box, Button, Menu, MenuItem, Paper, Typography } from "@mui/material";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { Field } from "@/app/(protected)/workorders/_components/view/work-order-timelines";
interface WorkOrderDetailsPanelProps {
wo: WorkOrderDetail;
users: WorkOrderUser[];
assignAnchor: HTMLElement | null;
onAssignAnchorChange: (anchor: HTMLElement | null) => void;
onAssign: (userId: string | number, userName: string) => void;
}
export function WorkOrderDetailsPanel({
wo,
users,
assignAnchor,
onAssignAnchorChange,
onAssign,
}: WorkOrderDetailsPanelProps) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Details
</Typography>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Internal WO" value={wo.internalWONumber} />
<Field label="Customer WO" value={wo.workOrderNumber || "—"} />
<Field label="Priority" value={wo.priority || "—"} />
<Field label="Location" value={wo.location || "—"} />
<Box>
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
Assigned To
</Typography>
<Button size="small" onClick={(event) => onAssignAnchorChange(event.currentTarget)}>
{wo.assignedTo || "Unassigned"}
</Button>
<Menu
anchorEl={assignAnchor}
open={Boolean(assignAnchor)}
onClose={() => onAssignAnchorChange(null)}
>
<MenuItem
onClick={() => {
onAssignAnchorChange(null);
onAssign("", "");
}}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={wo.assignedTo === user.name}
onClick={() => {
onAssignAnchorChange(null);
onAssign(user.id, user.name);
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</Box>
<Field label="Source" value={wo.source || "—"} />
<Field label="TT" value={wo.tt || "—"} />
</Box>
</Paper>
);
}

View file

@ -0,0 +1,117 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/work-order";
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
interface WorkOrderDispatchesAccordionProps {
dispatches: WorkOrderDispatchSummary[];
onSelectDispatch: (dispatchId: string | number) => void;
onEditVendor: (event: React.MouseEvent, vendorId: string | number, vendorName: string) => void;
}
function DispatchWaitCell({ dispatch }: { dispatch: WorkOrderDispatchSummary }) {
if (dispatch.status !== "Sent") {
return <>—</>;
}
const waitColor = waitTimeColor(dispatch.dispatchedAt);
const colorClass =
waitColor === "green"
? "text-green-600"
: waitColor === "yellow"
? "text-yellow-600"
: "text-red-600";
return (
<Typography variant="caption" className={colorClass}>
{timeSince(dispatch.dispatchedAt)}
</Typography>
);
}
export function WorkOrderDispatchesAccordion({
dispatches,
onSelectDispatch,
onEditVendor,
}: WorkOrderDispatchesAccordionProps) {
return (
<Accordion defaultExpanded={Boolean(dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Dispatches ({dispatches.length})</Typography>
</AccordionSummary>
<AccordionDetails>
{dispatches.length ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Dispatch #</TableCell>
<TableCell>Vendor</TableCell>
<TableCell>NTE</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{dispatches.map((dispatch) => (
<TableRow
key={String(dispatch.id)}
hover
className="cursor-pointer"
onClick={() => onSelectDispatch(dispatch.id)}
>
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
<TableCell>{dispatch.vendorName}</TableCell>
<TableCell>
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
</TableCell>
<TableCell>{dispatch.status}</TableCell>
<TableCell>
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
</TableCell>
<TableCell>
<DispatchWaitCell dispatch={dispatch} />
</TableCell>
<TableCell align="right">
<Button
size="small"
onClick={(event) =>
onEditVendor(event, dispatch.vendorId, dispatch.vendorName)
}
disabled={dispatch.vendorId === ""}
>
Edit vendor
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography
sx={{
color: "text.secondary",
}}
>
No dispatches yet.
</Typography>
)}
</AccordionDetails>
</Accordion>
);
}

View file

@ -0,0 +1,113 @@
import { Link as RouterLink } from "react-router";
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import {
WORK_ORDER_STATUS_OPTIONS,
type WorkOrderStatus,
} from "@/domain/work-orders/types/work-order";
const STATUS_COLORS: Record<
string,
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
> = {
Open: "primary",
"In Progress": "info",
"On Hold": "warning",
Done: "success",
Cancelled: "default",
};
interface WorkOrderHeaderSectionProps {
wo: WorkOrderDetail;
statusAnchor: HTMLElement | null;
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
onStatusChange: (status: WorkOrderStatus) => void;
onDispatch: () => void;
onEdit: () => void;
onBack: () => void;
}
export function WorkOrderHeaderSection({
wo,
statusAnchor,
onStatusAnchorChange,
onStatusChange,
onDispatch,
onEdit,
onBack,
}: WorkOrderHeaderSectionProps) {
return (
<>
<Breadcrumbs>
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography
sx={{
color: "text.primary",
}}
>
{wo.internalWONumber}
</Typography>
</Breadcrumbs>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
justifyContent: "space-between",
alignItems: { xs: "stretch", md: "flex-start" },
}}
>
<Stack spacing={1}>
<Typography variant="h5" component="h1">
{wo.title}
</Typography>
<Chip
label={wo.status}
color={STATUS_COLORS[wo.status] ?? "default"}
onClick={(event) => onStatusAnchorChange(event.currentTarget)}
className="w-fit cursor-pointer"
/>
<Menu
anchorEl={statusAnchor}
open={Boolean(statusAnchor)}
onClose={() => onStatusAnchorChange(null)}
>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem
key={status}
selected={status === wo.status}
onClick={() => {
onStatusAnchorChange(null);
if (status !== wo.status) {
onStatusChange(status);
}
}}
>
{status}
</MenuItem>
))}
</Menu>
</Stack>
<Stack
direction="row"
spacing={1}
sx={{
flexWrap: "wrap",
}}
>
<Button variant="contained" color="success" onClick={onDispatch}>
Dispatch
</Button>
<Button variant="contained" onClick={onEdit}>
Edit
</Button>
<Button variant="outlined" onClick={onBack}>
Back to List
</Button>
</Stack>
</Stack>
</>
);
}

View file

@ -0,0 +1,68 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import { formatDateTime } from "@/lib/time-utils";
import { Field } from "@/app/(protected)/workorders/_components/view/work-order-timelines";
export function WorkOrderInfoAccordions({ wo }: { wo: WorkOrderDetail }) {
return (
<>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Classification</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field label="Problem" value={wo.problem || "—"} />
<Field label="Trade" value={wo.trade || "—"} />
<Field label="Sub-Trade" value={wo.subTrade || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Schedule</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field
label="Created"
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
/>
<Field label="Due Date" value={wo.dueDate || "—"} />
<Field label="Scheduled" value={wo.scheduledDate || "—"} />
<Field label="Completed" value={wo.completedDate || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Financial</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field
label="Vendor NTE"
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
/>
<Field label="Customer PO" value={wo.po || "—"} />
<Field label="Customer NTE" value="Not connected" />
</Box>
</AccordionDetails>
</Accordion>
</>
);
}
export function WorkOrderDescriptionAccordion({ description }: { description: string }) {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
);
}

View file

@ -0,0 +1,125 @@
import { useMemo } from "react";
import { Box, Stack, Typography } from "@mui/material";
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
import { formatDateTime } from "@/lib/time-utils";
export function Field({ label, value }: { label: string; value: string }) {
return (
<Box>
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{label}
</Typography>
<Typography variant="body2">{value}</Typography>
</Box>
);
}
export function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
const sorted = useMemo(
() =>
[...comments].sort(
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
),
[comments],
);
if (!sorted.length) {
return (
<Typography
sx={{
color: "text.secondary",
}}
>
No comments yet.
</Typography>
);
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((comment) => (
<Box key={String(comment.id)} className="rounded border border-border p-3">
<Stack
direction="row"
className="mb-1"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{comment.userName || comment.commenter || "System"}
</Typography>
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
</Typography>
</Stack>
<Typography variant="body2">{comment.commenttext}</Typography>
</Box>
))}
</Stack>
);
}
export function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
const sorted = useMemo(
() =>
[...entries].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
),
[entries],
);
if (!sorted.length) {
return (
<Typography
sx={{
color: "text.secondary",
}}
>
No changes recorded.
</Typography>
);
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((entry) => (
<Box key={String(entry.id)} className="rounded border border-border p-3">
<Stack
direction="row"
className="mb-1"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{entry.userName || "System"}
</Typography>
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
</Typography>
</Stack>
<Typography variant="body2">
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
</Typography>
</Box>
))}
</Stack>
);
}

View file

@ -0,0 +1,101 @@
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
formatPhoneMask,
isVendorEditorValid,
type VendorEditorState,
} from "@/app/(protected)/workorders/_hooks/use-vendor-editor";
interface WorkOrderVendorEditorDialogProps {
vendorEditor: VendorEditorState | null;
isLoading: boolean;
isSaving: boolean;
error: string;
onClose: () => void;
onChange: (patch: Partial<VendorEditorState>) => void;
onSave: () => void;
}
export function WorkOrderVendorEditorDialog({
vendorEditor,
isLoading,
isSaving,
error,
onClose,
onChange,
onSave,
}: WorkOrderVendorEditorDialogProps) {
return (
<Dialog open={vendorEditor !== null} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>Edit assigned vendor</DialogTitle>
<DialogContent>
<Stack spacing={2} className="pt-2">
<Typography variant="body2" color="text.secondary">
{vendorEditor?.vendorName}
</Typography>
{Boolean(error) && <Alert severity="error">{error}</Alert>}
{isLoading ? (
<Box className="flex justify-center p-6">
<CircularProgress size={28} />
</Box>
) : (
<>
<TextField
label="Technician name"
value={vendorEditor?.contactName ?? ""}
onChange={(event) => onChange({ contactName: event.target.value })}
slotProps={{ htmlInput: { maxLength: 200 } }}
/>
<TextField
label="Phone"
value={vendorEditor?.phone ?? ""}
onChange={(event) => onChange({ phone: formatPhoneMask(event.target.value) })}
error={Boolean(
vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone),
)}
helperText="Use (XXX) XXX-XXXX"
/>
<TextField
label="Email"
type="email"
value={vendorEditor?.email ?? ""}
onChange={(event) => onChange({ email: event.target.value })}
/>
<TextField
label="Notes"
multiline
minRows={3}
value={vendorEditor?.notes ?? ""}
onChange={(event) => onChange({ notes: event.target.value })}
slotProps={{ htmlInput: { maxLength: 2000 } }}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
variant="contained"
onClick={onSave}
disabled={!vendorEditor || isLoading || isSaving || !isVendorEditorValid(vendorEditor)}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -52,21 +52,13 @@ export function useWizardDateFieldState({
const [rangeStart, setRangeStart] = useState(value || "");
const [rangeEnd, setRangeEnd] = useState(scheduleEnd || "");
useEffect(() => {
if (!open) {
return;
}
// Keep draft props as source of truth while open so parent clears
// (e.g. Clear schedule) cannot leave stale rangeStart/rangeEnd.
setRangeStart(value || "");
setRangeEnd(scheduleEnd || "");
}, [open, value, scheduleEnd]);
useEffect(() => {
if (open) {
setRangeStart(value || "");
setRangeEnd(scheduleEnd || "");
setCellMode(initialMode);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reseat mode only when the popover opens
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
function handleRangeClick(iso: string) {

View file

@ -41,7 +41,6 @@ export function WizardStepLocationSiteSelect({
siteCode: loc ? resolveSiteCode(loc) : "",
pocName: "",
pocPhone: "",
pocNotes: "",
});
}}
/>

View file

@ -2,7 +2,10 @@ 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";
@ -16,6 +19,7 @@ import {
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import {
ColorDot,
Detail,
WizLabel,
WizLabelRow,
@ -113,6 +117,8 @@ export function WizardStepVendorTime({
onPatch,
}: WizardStepVendorTimeProps) {
const selectedVendor = vendors.find((v) => 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 (
@ -228,6 +234,22 @@ export function WizardStepVendorTime({
</Text>
)}
</div>
<div>
<WizLabel>Status</WizLabel>
<WizardFieldSelect
value={statusValue}
placeholder="Select status…"
search={false}
icon={<ColorDot color={statusDotColor} />}
options={ALL_WIZARD_STATUSES.map((s) => ({
key: s,
label: s,
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
}))}
onPick={(s) => onUpdate("status", s as WizardWOStatus)}
/>
</div>
</div>
);
}

View file

@ -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<string | number | null>(null);
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
const [scheduledDate, setScheduledDate] = useState("");
const [description, setDescription] = useState(workOrder.description || "");
const [vendorSearch, setVendorSearch] = useState("");
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [customItems, setCustomItems] = useState<string[]>([]);
const [newCustomItem, setNewCustomItem] = useState("");
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrderRef[]>([
{
id: workOrder.id,
number: workOrder.internalWONumber,
title: workOrder.title,
},
]);
const [woSearch, setWoSearch] = useState("");
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
workOrder.trade,
workOrder.locationZip,
);
const { data: taskTemplates = [], isLoading: isLoadingTemplates } = useTaskListTemplates();
const dispatchMutation = useDispatchToVendor(workOrder.id);
const isPending = dispatchMutation.isPending;
const { data: siteWorkOrdersData } = useWorkOrdersList({
search: "",
page: 1,
pageSize: 500,
sortBy: "number",
sortDir: "asc",
locationId: workOrder.locationId,
});
const siteWorkOrders = useMemo(
() =>
(siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)),
[siteWorkOrdersData?.items, workOrder.id],
);
const filteredVendors = useMemo(
() =>
vendors.filter(
(vendor) =>
vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
vendor.contactName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
),
[vendors, vendorSearch],
);
const filteredSiteWOs = useMemo(
() =>
siteWorkOrders
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
.filter(
(item) =>
!woSearch ||
item.woNumber.includes(woSearch) ||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
)
.slice(0, 10),
[siteWorkOrders, selectedWOs, woSearch],
);
const addSelectedWO = (item: { id: string | number; woNumber: string; title: string }) => {
setSelectedWOs((prev) => [...prev, { id: item.id, number: item.woNumber, title: item.title }]);
setWoSearch("");
};
const removeSelectedWO = (id: string | number) => {
setSelectedWOs((prev) => prev.filter((item) => item.id !== id));
};
const addCustomItem = () => {
if (!newCustomItem.trim()) return;
setCustomItems((prev) => [...prev, newCustomItem.trim()]);
setNewCustomItem("");
};
const removeCustomItem = (index: number) => {
setCustomItems((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = () => {
if (!selectedVendorId) {
return;
}
dispatchMutation.mutate(
{
workOrderIds: selectedWOs.map((wo) => wo.id),
vendorIds: [selectedVendorId],
nteAmount: nteAmount ? parseFloat(nteAmount) : null,
scheduledDate: scheduledDate || null,
description,
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
customChecklistItems: customItems.length > 0 ? customItems : null,
},
{
onSuccess: () => {
onDispatched();
onClose();
},
},
);
};
return {
selectedVendorId,
setSelectedVendorId,
nteAmount,
setNteAmount,
scheduledDate,
setScheduledDate,
description,
setDescription,
vendorSearch,
setVendorSearch,
selectedTemplateId,
setSelectedTemplateId,
customItems,
newCustomItem,
setNewCustomItem,
addCustomItem,
removeCustomItem,
selectedWOs,
addSelectedWO,
removeSelectedWO,
woSearch,
setWoSearch,
vendors: filteredVendors,
isLoadingVendors,
taskTemplates,
isLoadingTemplates,
siteWorkOrders,
filteredSiteWOs,
isPending,
handleClose: guardDialogClose(isPending, onClose),
handleSubmit,
};
}
export type DispatchCreateController = ReturnType<typeof useDispatchCreateController>;

View file

@ -1,11 +1,6 @@
import { useMemo, useState } from "react";
import {
useApproveUplift,
useDenyUplift,
useUpliftsForDispatch,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
import { useDispatchUpliftController } from "@/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller";
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddChecklistItem,
@ -52,8 +47,8 @@ export function useDispatchDetailController(
workOrderId: string | number,
) {
const { user } = useAuthContext();
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
const { data: dispatch, isLoading, isFetching, refetch } = useDispatchDetail(dispatchId);
const uplift = useDispatchUpliftController(dispatchId);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState<DispatchEditFormState>({
@ -67,10 +62,6 @@ export function useDispatchDetailController(
const [commentText, setCommentText] = useState("");
const [sendEmail, setSendEmail] = useState(true);
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "deny";
uplift: UpliftRequest;
} | null>(null);
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
const addCommentMutation = useAddDispatchComment(dispatchId);
@ -78,8 +69,6 @@ export function useDispatchDetailController(
const addChecklistMutation = useAddChecklistItem(dispatchId);
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
const approveUpliftMutation = useApproveUplift(dispatchId);
const denyUpliftMutation = useDenyUplift(dispatchId);
const userName =
(typeof user?.name === "string" ? user.name : undefined) ??
@ -138,29 +127,22 @@ export function useDispatchDetailController(
);
};
const handleUpliftConfirm = (note: string) => {
if (!upliftAction) {
return;
}
if (upliftAction.type === "approve") {
approveUpliftMutation.mutate(
{ id: upliftAction.uplift.id, note },
{ onSuccess: () => setUpliftAction(null) },
);
return;
}
denyUpliftMutation.mutate(
{ id: upliftAction.uplift.id, note },
{ onSuccess: () => setUpliftAction(null) },
);
};
const isMutating =
updateDispatchMutation.isPending ||
addCommentMutation.isPending ||
updateChecklistMutation.isPending ||
addChecklistMutation.isPending ||
addSignoffMutation.isPending ||
verifyMutation.isPending ||
uplift.isMutating;
return {
dispatch,
isLoading,
uplifts,
isFetching,
refetch,
uplifts: uplift.uplifts,
isLoadingUplifts: uplift.isLoadingUplifts,
editing,
form,
setForm,
@ -172,24 +154,27 @@ export function useDispatchDetailController(
setSendEmail,
sigCaptureType,
setSigCaptureType,
upliftAction,
setUpliftAction,
upliftAction: uplift.upliftAction,
setUpliftAction: uplift.setUpliftAction,
isLocked,
isMutating,
userName,
verifyState,
beginEditing,
handleSave,
handleCancelDispatch,
handleComment,
handleUpliftConfirm,
handleUpliftConfirm: uplift.handleUpliftConfirm,
setEditing,
updateDispatchMutation,
updateChecklistMutation,
addChecklistMutation,
addSignoffMutation,
verifyMutation,
approveUpliftMutation,
denyUpliftMutation,
approveUpliftMutation: uplift.approveUpliftMutation,
denyUpliftMutation: uplift.denyUpliftMutation,
addCommentMutation,
};
}
export type DispatchDetailController = ReturnType<typeof useDispatchDetailController>;

View file

@ -0,0 +1,51 @@
import { useState } from "react";
import {
useApproveUplift,
useDenyUplift,
useUpliftsForDispatch,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
type UpliftAction = { type: "approve" | "deny"; uplift: UpliftRequest };
/**
* Owns the uplift approve/deny decision workflow for a dispatch: the pending
* uplifts list, which uplift (if any) the user is currently deciding on, and
* the mutations backing that decision.
*/
export function useDispatchUpliftController(dispatchId: string | number) {
const { data: uplifts = [], isLoading: isLoadingUplifts } = useUpliftsForDispatch(dispatchId);
const [upliftAction, setUpliftAction] = useState<UpliftAction | null>(null);
const approveUpliftMutation = useApproveUplift(dispatchId);
const denyUpliftMutation = useDenyUplift(dispatchId);
const handleUpliftConfirm = (note: string) => {
if (!upliftAction) {
return;
}
if (upliftAction.type === "approve") {
approveUpliftMutation.mutate(
{ id: upliftAction.uplift.id, note },
{ onSuccess: () => setUpliftAction(null) },
);
return;
}
denyUpliftMutation.mutate(
{ id: upliftAction.uplift.id, note },
{ onSuccess: () => setUpliftAction(null) },
);
};
return {
uplifts,
isLoadingUplifts,
upliftAction,
setUpliftAction,
approveUpliftMutation,
denyUpliftMutation,
handleUpliftConfirm,
isMutating: approveUpliftMutation.isPending || denyUpliftMutation.isPending,
};
}

View file

@ -0,0 +1,114 @@
import { useState } from "react";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import { useUpdateWorkOrderVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
export interface VendorEditorState {
vendorId: string | number;
vendorName: string;
contactName: string;
phone: string;
email: string;
notes: string;
}
export function formatPhoneMask(input: string): string {
const digits = input.replace(/\D/g, "").slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
export function isVendorEditorValid(editor: VendorEditorState): boolean {
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
return (
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
);
}
/**
* Owns the "edit assigned vendor" dialog state for a work order: loading the
* current vendor contact details, tracking in-flight edits, and saving them
* back via `useUpdateWorkOrderVendor`.
*/
export function useVendorEditor(workOrderId: string, onSaved: () => void) {
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
const [vendorEditorError, setVendorEditorError] = useState("");
const openVendorEditor = async (vendorId: string | number, vendorName: string) => {
setVendorEditorError("");
setVendorEditorLoading(true);
setVendorEditor({
vendorId,
vendorName,
contactName: "",
phone: "",
email: "",
notes: "",
});
try {
const vendor = await vendorsApi.getById(vendorId);
setVendorEditor({
vendorId,
vendorName: vendor.companyName || vendorName,
contactName: vendor.contactName,
phone: vendor.phone,
email: vendor.email,
notes: vendor.notes,
});
} catch (vendorError) {
setVendorEditorError(
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
);
} finally {
setVendorEditorLoading(false);
}
};
const closeVendorEditor = () => {
if (!vendorMutation.isPending) setVendorEditor(null);
};
const saveVendorEditor = () => {
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
const numericWorkOrderId = Number(workOrderId);
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
setVendorEditorError("A valid work order is required");
return;
}
vendorMutation.mutate(
{
vendorId: vendorEditor.vendorId,
payload: {
workOrderId: numericWorkOrderId,
contactName: vendorEditor.contactName.trim(),
phone: vendorEditor.phone,
email: vendorEditor.email.trim(),
notes: vendorEditor.notes.trim(),
},
},
{
onSuccess: () => {
setVendorEditor(null);
onSaved();
},
onError: (vendorError) => setVendorEditorError(vendorError.message),
},
);
};
return {
vendorEditor,
setVendorEditor,
vendorEditorLoading,
vendorEditorError,
vendorMutation,
openVendorEditor,
closeVendorEditor,
saveVendorEditor,
};
}

View file

@ -0,0 +1,82 @@
import { useMemo, useState } from "react";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { useVendorEditor } from "@/app/(protected)/workorders/_hooks/use-vendor-editor";
export type WorkOrderActivityTab = "customer" | "internal" | "audit";
export function useWorkOrderViewController(workOrderId: string) {
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const vendorEditor = useVendorEditor(workOrderId, () => void refetch());
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo?.comments],
);
const internalComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
[wo?.comments],
);
const auditLog = wo?.auditLog ?? [];
const handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
if (activeTab !== "customer" && activeTab !== "internal") {
return;
}
commentMutation.mutate(
{ text: commentText.trim(), commentType: activeTab },
{ onSuccess: () => setCommentText("") },
);
};
return {
wo,
isLoading,
error,
refetch,
users,
statusMutation,
assignmentMutation,
commentMutation,
vendorEditor,
activeTab,
setActiveTab,
commentText,
setCommentText,
dispatchOpen,
setDispatchOpen,
dispatchDetailId,
setDispatchDetailId,
statusAnchor,
setStatusAnchor,
assignAnchor,
setAssignAnchor,
customerComments,
internalComments,
auditLog,
handleCommentSubmit,
};
}
export type WorkOrderViewController = ReturnType<typeof useWorkOrderViewController>;

View file

@ -0,0 +1,91 @@
import { Box, Button, Tab, Tabs, TextField, Typography } from "@mui/material";
import type { RefObject } from "react";
interface SignatureCaptureFieldsProps {
isPending: boolean;
mode: "draw" | "type";
onModeChange: (mode: "draw" | "type") => void;
signerName: string;
onSignerNameChange: (value: string) => void;
typedName: string;
onTypedNameChange: (value: string) => void;
canvasRef: RefObject<HTMLCanvasElement | null>;
startDraw: (event: React.MouseEvent | React.TouchEvent) => void;
draw: (event: React.MouseEvent | React.TouchEvent) => void;
stopDraw: () => void;
clearCanvas: () => void;
}
export function SignatureCaptureFields({
isPending,
mode,
onModeChange,
signerName,
onSignerNameChange,
typedName,
onTypedNameChange,
canvasRef,
startDraw,
draw,
stopDraw,
clearCanvas,
}: SignatureCaptureFieldsProps) {
return (
<>
<TextField
label="Full Name"
value={signerName}
onChange={(event) => onSignerNameChange(event.target.value)}
placeholder="Enter signer's full name"
fullWidth
size="small"
disabled={isPending}
/>
<Tabs value={mode} onChange={(_event, value: "draw" | "type") => onModeChange(value)}>
<Tab label="Draw" value="draw" disabled={isPending} />
<Tab label="Type" value="type" disabled={isPending} />
</Tabs>
{mode === "draw" ? (
<Box className="relative">
<canvas
ref={canvasRef}
width={460}
height={180}
className="block w-full cursor-crosshair rounded border border-border bg-background"
onMouseDown={isPending ? undefined : startDraw}
onMouseMove={isPending ? undefined : draw}
onMouseUp={isPending ? undefined : stopDraw}
onMouseLeave={isPending ? undefined : stopDraw}
onTouchStart={isPending ? undefined : startDraw}
onTouchMove={isPending ? undefined : draw}
onTouchEnd={isPending ? undefined : stopDraw}
/>
<Button size="small" onClick={clearCanvas} className="mt-2" disabled={isPending}>
Clear
</Button>
</Box>
) : (
<Box className="flex flex-col gap-2">
<TextField
value={typedName}
onChange={(event) => onTypedNameChange(event.target.value)}
placeholder="Type your name"
fullWidth
size="small"
disabled={isPending}
/>
{Boolean(typedName) && (
<Typography
variant="h5"
className="rounded border border-dashed border-border px-4 py-6 text-center italic"
>
{typedName}
</Typography>
)}
</Box>
)}
</>
);
}

View file

@ -1,119 +1,38 @@
import { CSS_VAR_FALLBACKS, getCssVar } from "@/lib/theme/css-vars";
import { useEffect, useRef, useState } from "react";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useState } from "react";
import { createPortal } from "react-dom";
import CloseIcon from "@mui/icons-material/Close";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { SignatureCaptureFields } from "@/components/common/signature-capture-fields";
import { useSignatureCanvas } from "@/components/common/use-signature-canvas";
interface SignatureCaptureProps {
title?: string;
onClose: () => void;
onSave: (signature: string, method: "drawn" | "typed", name: string) => void;
isPending?: boolean;
}
export function SignatureCapture({ onClose, onSave, title }: SignatureCaptureProps) {
export function SignatureCapture({
onClose,
onSave,
title,
isPending = false,
}: SignatureCaptureProps) {
const [mode, setMode] = useState<"draw" | "type">("draw");
const [typedName, setTypedName] = useState("");
const [signerName, setSignerName] = useState("");
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawing = useRef(false);
useEffect(() => {
if (mode === "draw" && canvasRef.current) {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = getCssVar("--foreground", CSS_VAR_FALLBACKS.foreground);
ctx.lineWidth = 2;
ctx.lineCap = "round";
}
}, [mode]);
const getPos = (event: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) {
return { x: 0, y: 0 };
}
const rect = canvas.getBoundingClientRect();
const touch = "touches" in event ? event.touches[0] : event;
if (!touch) {
return { x: 0, y: 0 };
}
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
};
const startDraw = (event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
const canvas = canvasRef.current;
if (!canvas) {
return;
}
isDrawing.current = true;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
const pos = getPos(event);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
};
const draw = (event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
if (!isDrawing.current || !canvasRef.current) {
return;
}
const ctx = canvasRef.current.getContext("2d");
if (!ctx) {
return;
}
const pos = getPos(event);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
};
const stopDraw = () => {
isDrawing.current = false;
};
const clearCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const { canvasRef, startDraw, draw, stopDraw, clearCanvas } = useSignatureCanvas(mode);
const handleSave = () => {
if (!signerName.trim()) {
if (isPending || !signerName.trim()) {
return;
}
@ -134,77 +53,41 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
};
const dialog = (
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
<Dialog open onClose={guardDialogClose(isPending, onClose)} maxWidth="sm" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2">
{title || "Capture Signature"}
<IconButton aria-label="Close" onClick={onClose} size="small">
<IconButton aria-label="Close" onClick={onClose} size="small" disabled={isPending}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<TextField
label="Full Name"
value={signerName}
onChange={(event) => setSignerName(event.target.value)}
placeholder="Enter signer's full name"
fullWidth
size="small"
<SignatureCaptureFields
isPending={isPending}
mode={mode}
onModeChange={setMode}
signerName={signerName}
onSignerNameChange={setSignerName}
typedName={typedName}
onTypedNameChange={setTypedName}
canvasRef={canvasRef}
startDraw={startDraw}
draw={draw}
stopDraw={stopDraw}
clearCanvas={clearCanvas}
/>
<Tabs value={mode} onChange={(_event, value: "draw" | "type") => setMode(value)}>
<Tab label="Draw" value="draw" />
<Tab label="Type" value="type" />
</Tabs>
{mode === "draw" ? (
<Box className="relative">
<canvas
ref={canvasRef}
width={460}
height={180}
className="block w-full cursor-crosshair rounded border border-border bg-background"
onMouseDown={startDraw}
onMouseMove={draw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
onTouchStart={startDraw}
onTouchMove={draw}
onTouchEnd={stopDraw}
/>
<Button size="small" onClick={clearCanvas} className="mt-2">
Clear
</Button>
</Box>
) : (
<Box className="flex flex-col gap-2">
<TextField
value={typedName}
onChange={(event) => setTypedName(event.target.value)}
placeholder="Type your name"
fullWidth
size="small"
/>
{Boolean(typedName) && (
<Typography
variant="h5"
className="rounded border border-dashed border-border px-4 py-6 text-center italic"
>
{typedName}
</Typography>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!signerName.trim() || (mode === "type" && !typedName.trim())}
disabled={isPending || !signerName.trim() || (mode === "type" && !typedName.trim())}
>
Save Signature
{isPending ? "Saving…" : "Save Signature"}
</Button>
</DialogActions>
</Dialog>

View file

@ -0,0 +1,85 @@
import { CSS_VAR_FALLBACKS, getCssVar } from "@/lib/theme/css-vars";
import { useEffect, useRef } from "react";
export function useSignatureCanvas(mode: "draw" | "type") {
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawing = useRef(false);
useEffect(() => {
if (mode !== "draw" || !canvasRef.current) {
return;
}
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = getCssVar("--foreground", CSS_VAR_FALLBACKS.foreground);
ctx.lineWidth = 2;
ctx.lineCap = "round";
}, [mode]);
const getPos = (event: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) {
return { x: 0, y: 0 };
}
const rect = canvas.getBoundingClientRect();
const touch = "touches" in event ? event.touches[0] : event;
if (!touch) {
return { x: 0, y: 0 };
}
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
};
const startDraw = (event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
const canvas = canvasRef.current;
if (!canvas) {
return;
}
isDrawing.current = true;
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
const pos = getPos(event);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
};
const draw = (event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
if (!isDrawing.current || !canvasRef.current) {
return;
}
const ctx = canvasRef.current.getContext("2d");
if (!ctx) {
return;
}
const pos = getPos(event);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
};
const stopDraw = () => {
isDrawing.current = false;
};
const clearCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
return { canvasRef, startDraw, draw, stopDraw, clearCanvas };
}

View file

@ -6,11 +6,6 @@
import { API_PATHS } from "@/api/api-paths";
import { apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import {
disableAuthBypassForSession,
getBypassAuthUser,
isAuthBypassEnabled,
} from "@/lib/auth/auth-bypass";
import { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage";
import type {
AuthUser,
@ -27,18 +22,10 @@ export async function login(credentials: LoginCredentials): Promise<AuthUser> {
}
export async function logout(): Promise<void> {
if (isAuthBypassEnabled()) {
disableAuthBypassForSession();
}
clearAuth();
}
export async function getSession(): Promise<AuthUser | null> {
if (isAuthBypassEnabled()) {
return getBypassAuthUser();
}
const user = getAuthUser();
if (!user || !isSessionValid(user)) {

View file

@ -1,10 +1,9 @@
import { env } from "@/lib/env";
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
export function isWoWizardMockEnabled(): boolean {
return env.isDev;
}
export function shouldUseMockWoWizardCreate(locationId: string): boolean {
return isAuthBypassEnabled() || locationId.startsWith("mock-");
return env.isDev && locationId.startsWith("mock-");
}

View file

@ -85,12 +85,12 @@ export interface AddDispatchSignoffInput {
signatureMethod: string;
}
interface ChangeWorkOrderAssignmentInput {
export interface ChangeWorkOrderAssignmentInput {
userId: string | number;
userName: string;
}
interface UpdateWorkOrderVendorInput {
export interface UpdateWorkOrderVendorInput {
vendorId: string | number;
payload: WorkOrderVendorUpdatePayload;
}

View file

@ -1,36 +0,0 @@
import type { AuthUser } from "@/domain/auth/types/auth-user";
import { env } from "@/lib/env";
const BYPASS_DISABLED_KEY = "auth-bypass-disabled";
export function isAuthBypassEnabled(): boolean {
if (!env.isDev || !env.authBypass) {
return false;
}
if (typeof window !== "undefined" && sessionStorage.getItem(BYPASS_DISABLED_KEY)) {
return false;
}
return true;
}
export function getBypassAuthUser(): AuthUser {
return {
token: "dev-bypass-token",
expiration: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
email: "dev-bypass@seahaven.test",
userRoles: "Admin",
phoneNumber: "",
fullname: "Dev Bypass User",
id: "dev-bypass",
};
}
export function disableAuthBypassForSession(): void {
if (typeof window === "undefined") {
return;
}
sessionStorage.setItem(BYPASS_DISABLED_KEY, "1");
}

View file

@ -1,5 +1,4 @@
export const env = {
apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api",
isDev: import.meta.env.DEV,
authBypass: import.meta.env.VITE_AUTH_BYPASS === "true",
} as const;

View file

@ -1,5 +1,6 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { setupQueryBroadcast } from "@/lib/query/setup-query-broadcast";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
@ -30,4 +31,6 @@ export const queryClient = new QueryClient({
},
});
setupQueryBroadcast(queryClient);
export default queryClient;

View file

@ -0,0 +1,19 @@
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
import type { QueryClient } from "@tanstack/react-query";
/** Same-origin channel name for cross-tab QueryClient sync. */
export const QUERY_BROADCAST_CHANNEL = "seahaven-admin-query";
/**
* Syncs QueryClient cache (invalidations + data) across browser tabs via BroadcastChannel.
* No-op outside the browser and during Vitest runs.
*/
export function setupQueryBroadcast(queryClient: QueryClient): void {
if (typeof window === "undefined") return;
if (import.meta.env.MODE === "test") return;
broadcastQueryClient({
queryClient,
broadcastChannel: QUERY_BROADCAST_CHANNEL,
});
}

View file

@ -0,0 +1,62 @@
import { fireEvent, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { SignatureCapture } from "@/components/common/signature-capture";
import { renderWithProviders } from "@/test/test-utils";
/**
* Mirrors how a consumer wires `isPending` from a mutation: the flag flips to
* `true` synchronously once `onSave` fires and only clears when the caller
* decides to (e.g. on mutation settle), never inside `SignatureCapture` itself.
*/
function SignatureCaptureHarness({ onSave }: { onSave: () => void }) {
const [isPending, setIsPending] = useState(false);
return (
<SignatureCapture
title="Customer Sign-off"
isPending={isPending}
onClose={vi.fn()}
onSave={() => {
setIsPending(true);
onSave();
}}
/>
);
}
async function fillTypedSignature(user: ReturnType<typeof userEvent.setup>) {
await user.type(screen.getByPlaceholderText("Enter signer's full name"), "Jane Doe");
await user.click(screen.getByRole("tab", { name: "Type" }));
await user.type(screen.getByPlaceholderText("Type your name"), "Jane Doe");
}
describe("SignatureCapture", () => {
it("disables Save Signature and closing controls while isPending", () => {
renderWithProviders(
<SignatureCapture title="Vendor Sign-off" isPending onClose={vi.fn()} onSave={vi.fn()} />,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Close" })).toBeDisabled();
});
it("calls onSave exactly once when Save Signature is double-clicked before the mutation settles", async () => {
const user = userEvent.setup();
const handleSave = vi.fn();
renderWithProviders(<SignatureCaptureHarness onSave={handleSave} />, { withAuth: false });
await fillTypedSignature(user);
const saveButton = screen.getByRole("button", { name: "Save Signature" });
fireEvent.click(saveButton);
fireEvent.click(saveButton);
expect(handleSave).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
});
});

View file

@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
const broadcastQueryClient = vi.fn();
vi.mock("@tanstack/query-broadcast-client-experimental", () => ({
broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args),
}));
describe("setupQueryBroadcast", () => {
beforeEach(() => {
broadcastQueryClient.mockClear();
vi.unstubAllEnvs();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("does not call broadcastQueryClient when MODE is test", async () => {
vi.stubEnv("MODE", "test");
const { setupQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
setupQueryBroadcast(client);
expect(broadcastQueryClient).not.toHaveBeenCalled();
});
it("calls broadcastQueryClient with the app channel when MODE is not test", async () => {
vi.stubEnv("MODE", "development");
vi.resetModules();
const { setupQueryBroadcast, QUERY_BROADCAST_CHANNEL } =
await import("@/lib/query/setup-query-broadcast");
const client = new QueryClient();
setupQueryBroadcast(client);
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
expect(broadcastQueryClient).toHaveBeenCalledWith({
queryClient: client,
broadcastChannel: QUERY_BROADCAST_CHANNEL,
});
expect(QUERY_BROADCAST_CHANNEL).toBe("seahaven-admin-query");
});
});

View file

@ -1,11 +1,44 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
});
function localStorageIsUsable(): boolean {
try {
const probe = "__vitest_ls_probe__";
globalThis.localStorage.setItem(probe, "1");
const ok = globalThis.localStorage.getItem(probe) === "1";
globalThis.localStorage.removeItem(probe);
return ok;
} catch {
return false;
}
}
// Node 25+ / some harnesses lack a stable localStorage — provide an in-memory mock when needed.
if (!localStorageIsUsable()) {
const store = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
get length() {
return store.size;
},
key: (index: number) => [...store.keys()][index] ?? null,
});
}
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({

2
src/vite-env.d.ts vendored
View file

@ -3,8 +3,6 @@
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_TARGET: string;
readonly VITE_AUTH_BYPASS?: string;
readonly VITE_WO_TABLE_MOCK?: string;
}
interface ImportMeta {