diff --git a/docs/work-orders/pending-be-contract.md b/docs/work-orders/pending-be-contract.md new file mode 100644 index 00000000..08aff844 --- /dev/null +++ b/docs/work-orders/pending-be-contract.md @@ -0,0 +1,30 @@ +# Work-order `additionalContacts` contract (SH-218) + +Authoritative frontend contract for additional POC 2+ contacts. Implemented on +backend PR [shoc-backend#66](https://github.com/Sea-Haven-Industries/shoc-backend/pull/66). + +## Shape + +```ts +{ name: string; phone: string; notes?: string } +``` + +- Primary POC remains `pocName` / `pocPhone` / `pocNotes`. +- `additionalContacts` holds ordered POC 2+ only. +- Notes are optional. An empty list (or omitted field) is valid. +- Retained entries require **both** `name` and `phone`. Fully blank placeholder + rows are dropped and must not fail create. Name-only or phone-only entries are + rejected on POST create and PATCH. + +## Endpoints + +| Operation | Where | +| ------------------ | ------------------------------------------------------------- | +| POST create | `additionalContacts` array on the board create body | +| GET board / search | `additionalContacts` on each board row | +| GET detail | `info.additionalContacts` (same ordered list as board) | +| PATCH | `field: "additionalContacts"`, `value` is a JSON array string | + +Until #66 is merged to the deployed API, a missing field is still mapped as `[]` +on the frontend so the UI does not throw. After merge, create → reload → +board/slide-over must round-trip the ordered list through GET detail `info`. diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index d3882148..8c63b1b0 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -11,6 +11,12 @@ import { WorkOrderViewAccordions } from "./_components/view/work-order-view-acco import { WorkOrderViewDetails } from "./_components/view/work-order-view-details"; import { WorkOrderViewHeader } from "./_components/view/work-order-view-header"; import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page"; +import { + detailToClosabilityInput, + getClosabilityGaps, + isCompletionStatusLabel, +} from "@/domain/work-orders/utils/wo-closability"; +import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order"; export default function WorkOrderViewPage() { const { id } = useParams(); @@ -32,14 +38,28 @@ export default function WorkOrderViewPage() { ); } + const wo = view.wo; + + const handleStatusChange = (status: string) => { + const isCompletionStatus = isCompletionStatusLabel(status); + if ( + isCompletionStatus && + (!view.isClosabilityReady || getClosabilityGaps(detailToClosabilityInput(wo)).length > 0) + ) { + return; + } + view.statusMutation.mutate(status as WorkOrderStatus); + }; + return ( view.statusMutation.mutate(status)} + onStatusChange={handleStatusChange} onOpenDispatch={() => view.setDispatchOpen(true)} /> diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 7eaf94f3..bdfe8af9 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -59,18 +59,29 @@ export function SlideOverInfoTabEditView({ pocName={draft.pocName} pocPhone={draft.pocPhone} pocNotes={draft.pocNotes} + additionalContacts={draft.additionalContacts} sites={sites} onSave={(patch) => onDraftChange({ site: patch.site, location: patch.location, locationId: patch.locationId, + pocName: patch.pocName, + pocPhone: patch.pocPhone, + pocNotes: patch.pocNotes, + ...(patch.additionalContacts !== undefined + ? { additionalContacts: patch.additionalContacts } + : {}), }) } /> - onDraftChange({ type })} /> + onDraftChange({ type, severity })} + /> void; }; export function WorkOrderStatusMenu({ status, disabled = false, + closability, onStatusChange, }: WorkOrderStatusMenuProps) { const { anchorEl, open, setAnchor, close } = useMenuAnchor(); + const closabilityGaps = getClosabilityGaps(closability); return ( <> @@ -29,21 +38,31 @@ export function WorkOrderStatusMenu({ - {WORK_ORDER_STATUS_OPTIONS.map((option) => ( - { - close(); - if (option !== status) { - onStatusChange(option); + {WORK_ORDER_STATUS_OPTIONS.map((option) => { + const isCompletionStatus = isCompletionStatusLabel(option); + const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0; + return ( + - {option} - - ))} + onClick={() => { + if (isCompletedBlocked) { + return; + } + close(); + if (option !== status) { + onStatusChange(option); + } + }} + > + {option} + + ); + })} ); diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index b18ef7d7..68605ce8 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -39,6 +39,7 @@ export function buildInlineDraftCreateInput( pocName: draft.pocName, pocPhone: draft.pocPhone, pocNotes: draft.pocNotes, + additionalContacts: draft.additionalContacts ?? [], scheduleWeekOnly, targetWeek: draft.targetWeek || "", isAddOnOverride: draft.isAddOnOverride, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts b/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts new file mode 100644 index 00000000..3fe930cd --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts @@ -0,0 +1,42 @@ +import { extractLocationLabel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; +import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types"; +import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; + +type BuildSiteDialogPatchArgs = { + code: string; + locId: string; + selected: LocationOption | undefined; + pocName: string; + pocPhone: string; + pocNotes: string; + extraContacts: WorkOrderAdditionalContact[]; + contactsDirty: boolean; + baselineHadContacts: boolean; +}; + +export function buildSiteDialogPatch({ + code, + locId, + selected, + pocName, + pocPhone, + pocNotes, + extraContacts, + contactsDirty, + baselineHadContacts, +}: BuildSiteDialogPatchArgs): SitePatch { + const patch: SitePatch = { + site: code || (selected ? resolveSiteCode(selected) : ""), + location: selected ? extractLocationLabel(selected) : "", + locationId: locId, + pocName, + pocPhone, + pocNotes, + }; + if (contactsDirty || baselineHadContacts) { + patch.additionalContacts = extraContacts; + } + return patch; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx index 89a703f6..104286d5 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { MapPin } from "lucide-react"; import { formatSitePlace } from "@/domain/locations/mappers/location-mapper"; +import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; import { HighlightText } from "./highlight-text"; import { HoverChevron } from "./hover-chevron"; @@ -15,6 +16,7 @@ type SiteCellProps = { pocName?: string; pocPhone?: string; pocNotes?: string; + additionalContacts?: WorkOrderAdditionalContact[]; sites: LocationOption[]; q?: string; /** Inline create: editable POC + Site/POC required to confirm the modal. */ @@ -31,6 +33,7 @@ export function SiteCell({ pocName, pocPhone, pocNotes, + additionalContacts, sites, q, createMode = false, @@ -93,6 +96,7 @@ export function SiteCell({ pocName={pocName} pocPhone={pocPhone} pocNotes={pocNotes} + additionalContacts={additionalContacts} sites={sites} createMode={createMode} onSave={onSave} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx new file mode 100644 index 00000000..ed269746 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx @@ -0,0 +1,19 @@ +import { Button } from "@/components/ui/button"; + +type SiteDialogFooterProps = { + onClose: () => void; + onConfirm: () => void; +}; + +export function SiteDialogFooter({ onClose, onConfirm }: SiteDialogFooterProps) { + return ( +
+ + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx index 61ca8f78..66874d72 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx @@ -1,87 +1 @@ -import { - DLG_INPUT_CLS, - DLG_INPUT_STYLE, -} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; -import { Text } from "@/components/ui/text"; -import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; - -type SiteDialogPocFieldsProps = { - pocName: string; - pocPhone: string; - pocNotes: string; - editable?: boolean; - showErrors?: boolean; - onPocNameChange?: (value: string) => void; - onPocPhoneChange?: (value: string) => void; - onPocNotesChange?: (value: string) => void; -}; - -export function SiteDialogPocFields({ - pocName, - pocPhone, - pocNotes, - editable = false, - showErrors = false, - onPocNameChange, - onPocPhoneChange, - onPocNotesChange, -}: SiteDialogPocFieldsProps) { - const nameMissing = showErrors && !pocName.trim(); - const phoneMissing = showErrors && !pocPhone.trim(); - - return ( -
- Point of Contact - - {editable - ? "Auto-filled from records — edit if outdated" - : "Shown from site/work order records. Board edit saves site code only."} - -
-
- onPocNameChange?.(e.target.value)} - /> - - Contact name is required - -
-
- onPocPhoneChange?.(e.target.value)} - /> - - Contact phone is required - -
-