mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 05:03:12 +00:00
fix(work-orders): remove legacy completedDate EditWorkorder path
[recover] remove malicious eslint payload (was f4e6132b)
This commit is contained in:
parent
2eb5359d82
commit
6fe1cd0f27
53 changed files with 2367 additions and 865 deletions
81
docs/adr/0001-query-broadcast-client.md
Normal file
81
docs/adr/0001-query-broadcast-client.md
Normal 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
104
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export function SlideOverInfoTab({
|
|||
source={source}
|
||||
isCancelled={isCancelled}
|
||||
dispatcher={dispatcher}
|
||||
vendors={vendors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export function WizardStepLocationSiteSelect({
|
|||
siteCode: loc ? resolveSiteCode(loc) : "",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
114
src/app/(protected)/workorders/_hooks/use-vendor-editor.ts
Normal file
114
src/app/(protected)/workorders/_hooks/use-vendor-editor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>;
|
||||
91
src/components/common/signature-capture-fields.tsx
Normal file
91
src/components/common/signature-capture-fields.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
85
src/components/common/use-signature-canvas.ts
Normal file
85
src/components/common/use-signature-canvas.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
19
src/lib/query/setup-query-broadcast.ts
Normal file
19
src/lib/query/setup-query-broadcast.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
62
src/test/components/common/signature-capture.test.tsx
Normal file
62
src/test/components/common/signature-capture.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
47
src/test/lib/query/setup-query-broadcast.test.ts
Normal file
47
src/test/lib/query/setup-query-broadcast.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
2
src/vite-env.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue