mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 09:02:05 +00:00
Merge pull request #107 from Sea-Haven-Industries/feature/wo-multi-poc-contacts
feat(work-orders): multiple additional POC contacts (SH-218)
This commit is contained in:
commit
772d7f94df
49 changed files with 991 additions and 196 deletions
30
docs/work-orders/pending-be-contract.md
Normal file
30
docs/work-orders/pending-be-contract.md
Normal file
|
|
@ -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`.
|
||||||
|
|
@ -59,18 +59,29 @@ export function SlideOverInfoTabEditView({
|
||||||
pocName={draft.pocName}
|
pocName={draft.pocName}
|
||||||
pocPhone={draft.pocPhone}
|
pocPhone={draft.pocPhone}
|
||||||
pocNotes={draft.pocNotes}
|
pocNotes={draft.pocNotes}
|
||||||
|
additionalContacts={draft.additionalContacts}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
onSave={(patch) =>
|
onSave={(patch) =>
|
||||||
onDraftChange({
|
onDraftChange({
|
||||||
site: patch.site,
|
site: patch.site,
|
||||||
location: patch.location,
|
location: patch.location,
|
||||||
locationId: patch.locationId,
|
locationId: patch.locationId,
|
||||||
|
pocName: patch.pocName,
|
||||||
|
pocPhone: patch.pocPhone,
|
||||||
|
pocNotes: patch.pocNotes,
|
||||||
|
...(patch.additionalContacts !== undefined
|
||||||
|
? { additionalContacts: patch.additionalContacts }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Type of WO">
|
<SlideOverInfoRow label="Type of WO">
|
||||||
<TypeCell value={draft.type} onSave={(type) => onDraftChange({ type })} />
|
<TypeCell
|
||||||
|
value={draft.type}
|
||||||
|
severity={draft.severity}
|
||||||
|
onSave={(type, severity) => onDraftChange({ type, severity })}
|
||||||
|
/>
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Assigned To">
|
<SlideOverInfoRow label="Assigned To">
|
||||||
<AssignedToCell
|
<AssignedToCell
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export function buildInlineDraftCreateInput(
|
||||||
pocName: draft.pocName,
|
pocName: draft.pocName,
|
||||||
pocPhone: draft.pocPhone,
|
pocPhone: draft.pocPhone,
|
||||||
pocNotes: draft.pocNotes,
|
pocNotes: draft.pocNotes,
|
||||||
|
additionalContacts: draft.additionalContacts ?? [],
|
||||||
scheduleWeekOnly,
|
scheduleWeekOnly,
|
||||||
targetWeek: draft.targetWeek || "",
|
targetWeek: draft.targetWeek || "",
|
||||||
isAddOnOverride: draft.isAddOnOverride,
|
isAddOnOverride: draft.isAddOnOverride,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
import { formatSitePlace } from "@/domain/locations/mappers/location-mapper";
|
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 type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import { HighlightText } from "./highlight-text";
|
import { HighlightText } from "./highlight-text";
|
||||||
import { HoverChevron } from "./hover-chevron";
|
import { HoverChevron } from "./hover-chevron";
|
||||||
|
|
@ -15,6 +16,7 @@ type SiteCellProps = {
|
||||||
pocName?: string;
|
pocName?: string;
|
||||||
pocPhone?: string;
|
pocPhone?: string;
|
||||||
pocNotes?: string;
|
pocNotes?: string;
|
||||||
|
additionalContacts?: WorkOrderAdditionalContact[];
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
q?: string;
|
q?: string;
|
||||||
/** Inline create: editable POC + Site/POC required to confirm the modal. */
|
/** Inline create: editable POC + Site/POC required to confirm the modal. */
|
||||||
|
|
@ -31,6 +33,7 @@ export function SiteCell({
|
||||||
pocName,
|
pocName,
|
||||||
pocPhone,
|
pocPhone,
|
||||||
pocNotes,
|
pocNotes,
|
||||||
|
additionalContacts,
|
||||||
sites,
|
sites,
|
||||||
q,
|
q,
|
||||||
createMode = false,
|
createMode = false,
|
||||||
|
|
@ -93,6 +96,7 @@ export function SiteCell({
|
||||||
pocName={pocName}
|
pocName={pocName}
|
||||||
pocPhone={pocPhone}
|
pocPhone={pocPhone}
|
||||||
pocNotes={pocNotes}
|
pocNotes={pocNotes}
|
||||||
|
additionalContacts={additionalContacts}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
createMode={createMode}
|
createMode={createMode}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type SiteDialogFooterProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SiteDialogFooter({ onClose, onConfirm }: SiteDialogFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={onConfirm}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,87 +1 @@
|
||||||
import {
|
export { PocContactFields as SiteDialogPocFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
|
||||||
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 (
|
|
||||||
<div className="pt-1">
|
|
||||||
<FieldLabel>Point of Contact</FieldLabel>
|
|
||||||
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
{editable
|
|
||||||
? "Auto-filled from records — edit if outdated"
|
|
||||||
: "Shown from site/work order records. Board edit saves site code only."}
|
|
||||||
</Text>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
value={pocName}
|
|
||||||
readOnly={!editable}
|
|
||||||
placeholder="POC name"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={{
|
|
||||||
...DLG_INPUT_STYLE,
|
|
||||||
opacity: editable ? 1 : 0.85,
|
|
||||||
borderColor: nameMissing ? "var(--destructive)" : undefined,
|
|
||||||
}}
|
|
||||||
onChange={(e) => onPocNameChange?.(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
|
|
||||||
Contact name is required
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
value={pocPhone}
|
|
||||||
readOnly={!editable}
|
|
||||||
placeholder="POC phone"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={{
|
|
||||||
...DLG_INPUT_STYLE,
|
|
||||||
opacity: editable ? 1 : 0.85,
|
|
||||||
borderColor: phoneMissing ? "var(--destructive)" : undefined,
|
|
||||||
}}
|
|
||||||
onChange={(e) => onPocPhoneChange?.(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
|
|
||||||
Contact phone is required
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={pocNotes}
|
|
||||||
readOnly={!editable}
|
|
||||||
placeholder="Notes…"
|
|
||||||
rows={2}
|
|
||||||
className={`${DLG_INPUT_CLS} resize-none`}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: editable ? 1 : 0.85 }}
|
|
||||||
onChange={(e) => onPocNotesChange?.(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
|
||||||
|
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
|
|
||||||
|
type SiteDialogPocSectionProps = {
|
||||||
|
pocName: string;
|
||||||
|
pocPhone: string;
|
||||||
|
pocNotes: string;
|
||||||
|
additionalContacts: WorkOrderAdditionalContact[];
|
||||||
|
showPrimaryErrors?: boolean;
|
||||||
|
onPocNameChange: (value: string) => void;
|
||||||
|
onPocPhoneChange: (value: string) => void;
|
||||||
|
onPocNotesChange: (value: string) => void;
|
||||||
|
onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SiteDialogPocSection({
|
||||||
|
pocName,
|
||||||
|
pocPhone,
|
||||||
|
pocNotes,
|
||||||
|
additionalContacts,
|
||||||
|
showPrimaryErrors = false,
|
||||||
|
onPocNameChange,
|
||||||
|
onPocPhoneChange,
|
||||||
|
onPocNotesChange,
|
||||||
|
onAdditionalContactsChange,
|
||||||
|
}: SiteDialogPocSectionProps) {
|
||||||
|
return (
|
||||||
|
<PocContactFields
|
||||||
|
pocName={pocName}
|
||||||
|
pocPhone={pocPhone}
|
||||||
|
pocNotes={pocNotes}
|
||||||
|
additionalContacts={additionalContacts}
|
||||||
|
showPrimaryErrors={showPrimaryErrors}
|
||||||
|
onPocNameChange={onPocNameChange}
|
||||||
|
onPocPhoneChange={onPocPhoneChange}
|
||||||
|
onPocNotesChange={onPocNotesChange}
|
||||||
|
onAdditionalContactsChange={onAdditionalContactsChange}
|
||||||
|
helperText="Auto-filled from site records when empty. Additional contacts persist on save."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
|
|
||||||
|
export type SitePatch = {
|
||||||
|
site: string;
|
||||||
|
location: string;
|
||||||
|
locationId: string | number;
|
||||||
|
pocName: string;
|
||||||
|
pocPhone: string;
|
||||||
|
pocNotes: string;
|
||||||
|
/** Omitted for site-only saves when GET had no contacts and the user did not edit them. */
|
||||||
|
additionalContacts?: WorkOrderAdditionalContact[];
|
||||||
|
};
|
||||||
|
|
@ -2,17 +2,16 @@ import type { MouseEvent } from "react";
|
||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
|
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
|
||||||
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
|
import { SiteDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer";
|
||||||
import {
|
import { SiteDialogPocSection } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section";
|
||||||
useSiteDialogState,
|
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
|
||||||
type SitePatch,
|
import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
|
||||||
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
|
|
||||||
|
|
||||||
export type { SitePatch };
|
export type { SitePatch };
|
||||||
|
|
||||||
|
|
@ -24,6 +23,7 @@ type SiteDialogProps = {
|
||||||
pocName?: string;
|
pocName?: string;
|
||||||
pocPhone?: string;
|
pocPhone?: string;
|
||||||
pocNotes?: string;
|
pocNotes?: string;
|
||||||
|
additionalContacts?: WorkOrderAdditionalContact[];
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
createMode?: boolean;
|
createMode?: boolean;
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
|
|
@ -37,6 +37,7 @@ export function SiteDialog({
|
||||||
pocName = "",
|
pocName = "",
|
||||||
pocPhone = "",
|
pocPhone = "",
|
||||||
pocNotes = "",
|
pocNotes = "",
|
||||||
|
additionalContacts = [],
|
||||||
sites,
|
sites,
|
||||||
createMode = false,
|
createMode = false,
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -49,6 +50,7 @@ export function SiteDialog({
|
||||||
pocName,
|
pocName,
|
||||||
pocPhone,
|
pocPhone,
|
||||||
pocNotes,
|
pocNotes,
|
||||||
|
additionalContacts,
|
||||||
sites,
|
sites,
|
||||||
createMode,
|
createMode,
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -116,26 +118,20 @@ export function SiteDialog({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SiteDialogPocFields
|
<SiteDialogPocSection
|
||||||
pocName={s.pn}
|
pocName={s.pn}
|
||||||
pocPhone={s.pp}
|
pocPhone={s.pp}
|
||||||
pocNotes={s.notes}
|
pocNotes={s.notes}
|
||||||
editable={createMode}
|
additionalContacts={s.extraContacts}
|
||||||
showErrors={createMode && s.showErrors}
|
showPrimaryErrors={createMode && s.showErrors}
|
||||||
onPocNameChange={s.setPn}
|
onPocNameChange={s.setPn}
|
||||||
onPocPhoneChange={s.setPp}
|
onPocPhoneChange={s.setPp}
|
||||||
onPocNotesChange={s.setNotes}
|
onPocNotesChange={s.setNotes}
|
||||||
|
onAdditionalContactsChange={s.setExtraContacts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<SiteDialogFooter onClose={s.close} onConfirm={s.attemptSave} />
|
||||||
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
|
||||||
|
import { resolveLocationId } 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 { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
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";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import {
|
|
||||||
extractLocationLabel,
|
|
||||||
resolveLocationId,
|
|
||||||
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
|
||||||
|
|
||||||
export type SitePatch = {
|
export type { SitePatch };
|
||||||
site: string;
|
|
||||||
location: string;
|
|
||||||
locationId: string | number;
|
|
||||||
pocName: string;
|
|
||||||
pocPhone: string;
|
|
||||||
pocNotes: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UseSiteDialogStateArgs = {
|
type UseSiteDialogStateArgs = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -25,6 +18,7 @@ type UseSiteDialogStateArgs = {
|
||||||
pocName: string;
|
pocName: string;
|
||||||
pocPhone: string;
|
pocPhone: string;
|
||||||
pocNotes: string;
|
pocNotes: string;
|
||||||
|
additionalContacts: WorkOrderAdditionalContact[];
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
createMode: boolean;
|
createMode: boolean;
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
|
|
@ -38,6 +32,7 @@ export function useSiteDialogState({
|
||||||
pocName,
|
pocName,
|
||||||
pocPhone,
|
pocPhone,
|
||||||
pocNotes,
|
pocNotes,
|
||||||
|
additionalContacts = [],
|
||||||
sites,
|
sites,
|
||||||
createMode,
|
createMode,
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -47,7 +42,13 @@ export function useSiteDialogState({
|
||||||
const [pn, setPn] = useState(pocName);
|
const [pn, setPn] = useState(pocName);
|
||||||
const [pp, setPp] = useState(pocPhone);
|
const [pp, setPp] = useState(pocPhone);
|
||||||
const [notes, setNotes] = useState(pocNotes);
|
const [notes, setNotes] = useState(pocNotes);
|
||||||
|
const [extraContacts, setExtraContacts] = useState(additionalContacts);
|
||||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [contactsDirty, setContactsDirty] = useState(false);
|
||||||
|
const [baselineHadContacts, setBaselineHadContacts] = useState(
|
||||||
|
() => additionalContacts.length > 0,
|
||||||
|
);
|
||||||
const [showErrors, setShowErrors] = useState(false);
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -64,6 +65,14 @@ export function useSiteDialogState({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
setDirty(false);
|
||||||
|
setContactsDirty(false);
|
||||||
|
setShowErrors(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || dirty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolved = resolveLocationId(locationId, value, sites);
|
const resolved = resolveLocationId(locationId, value, sites);
|
||||||
|
|
@ -72,15 +81,15 @@ export function useSiteDialogState({
|
||||||
setPn(pocName);
|
setPn(pocName);
|
||||||
setPp(pocPhone);
|
setPp(pocPhone);
|
||||||
setNotes(pocNotes);
|
setNotes(pocNotes);
|
||||||
setShowErrors(false);
|
setExtraContacts(additionalContacts);
|
||||||
|
setBaselineHadContacts(additionalContacts.length > 0);
|
||||||
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||||
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
|
}, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Only autofill empty fields so typed createMode POC edits are not overwritten.
|
|
||||||
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
||||||
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
||||||
setPocFilledFor(locId);
|
setPocFilledFor(locId);
|
||||||
|
|
@ -94,49 +103,72 @@ export function useSiteDialogState({
|
||||||
setCode(resolveSiteCode(next));
|
setCode(resolveSiteCode(next));
|
||||||
}
|
}
|
||||||
if (siteChanged) {
|
if (siteChanged) {
|
||||||
setPn("");
|
setExtraContacts([]);
|
||||||
setPp("");
|
setContactsDirty(true);
|
||||||
|
setDirty(true);
|
||||||
|
if (createMode) {
|
||||||
|
setPn("");
|
||||||
|
setPp("");
|
||||||
|
}
|
||||||
setPocFilledFor("");
|
setPocFilledFor("");
|
||||||
} else if (id !== pocFilledFor) {
|
} else if (id !== pocFilledFor) {
|
||||||
setPocFilledFor("");
|
setPocFilledFor("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildPatch = (): SitePatch =>
|
||||||
|
buildSiteDialogPatch({
|
||||||
|
code,
|
||||||
|
locId,
|
||||||
|
selected,
|
||||||
|
pocName: pn,
|
||||||
|
pocPhone: pp,
|
||||||
|
pocNotes: notes,
|
||||||
|
extraContacts,
|
||||||
|
contactsDirty,
|
||||||
|
baselineHadContacts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markPocDirty = (apply: () => void) => {
|
||||||
|
setDirty(true);
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
|
||||||
|
const markContactsDirty = (contacts: WorkOrderAdditionalContact[]) => {
|
||||||
|
setDirty(true);
|
||||||
|
setContactsDirty(true);
|
||||||
|
setExtraContacts(contacts);
|
||||||
|
};
|
||||||
|
|
||||||
const attemptSave = () => {
|
const attemptSave = () => {
|
||||||
if (createMode && !canConfirm) {
|
if (createMode && !canConfirm) {
|
||||||
setShowErrors(true);
|
setShowErrors(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave({
|
onSave(buildPatch());
|
||||||
site: code || (selected ? resolveSiteCode(selected) : ""),
|
|
||||||
location: selected ? extractLocationLabel(selected) : "",
|
|
||||||
locationId: locId,
|
|
||||||
pocName: pn,
|
|
||||||
pocPhone: pp,
|
|
||||||
pocNotes: notes,
|
|
||||||
});
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locId,
|
locId,
|
||||||
pn,
|
code,
|
||||||
pp,
|
selected,
|
||||||
notes,
|
|
||||||
showErrors,
|
|
||||||
locationDetail,
|
locationDetail,
|
||||||
locationDetailLoading,
|
locationDetailLoading,
|
||||||
locationDetailError,
|
locationDetailError,
|
||||||
selected,
|
|
||||||
addressPreview,
|
addressPreview,
|
||||||
|
pn,
|
||||||
|
pp,
|
||||||
|
notes,
|
||||||
|
extraContacts,
|
||||||
|
showErrors,
|
||||||
siteMissing,
|
siteMissing,
|
||||||
canConfirm,
|
|
||||||
code,
|
|
||||||
handlePick,
|
handlePick,
|
||||||
attemptSave,
|
attemptSave,
|
||||||
setPn,
|
setPn: (next: string) => markPocDirty(() => setPn(next)),
|
||||||
setPp,
|
setPp: (next: string) => markPocDirty(() => setPp(next)),
|
||||||
setNotes,
|
setNotes: (next: string) => markPocDirty(() => setNotes(next)),
|
||||||
|
setExtraContacts: markContactsDirty,
|
||||||
close: () => onOpenChange(false),
|
close: () => onOpenChange(false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export function WoTableInlineIdentityCells({
|
||||||
pocName={draft.pocName}
|
pocName={draft.pocName}
|
||||||
pocPhone={draft.pocPhone}
|
pocPhone={draft.pocPhone}
|
||||||
pocNotes={draft.pocNotes}
|
pocNotes={draft.pocNotes}
|
||||||
|
additionalContacts={draft.additionalContacts}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
createMode
|
createMode
|
||||||
onSave={(p) =>
|
onSave={(p) =>
|
||||||
|
|
@ -65,6 +66,9 @@ export function WoTableInlineIdentityCells({
|
||||||
pocName: p.pocName,
|
pocName: p.pocName,
|
||||||
pocPhone: p.pocPhone,
|
pocPhone: p.pocPhone,
|
||||||
pocNotes: p.pocNotes,
|
pocNotes: p.pocNotes,
|
||||||
|
...(p.additionalContacts !== undefined
|
||||||
|
? { additionalContacts: p.additionalContacts }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,6 @@ export function WoTableInlineRowCells({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} />
|
<InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} />
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
|
@ -170,7 +169,6 @@ export function WoTableInlineRowCells({
|
||||||
onSave={(date) => onPatch({ dueDate: date })}
|
onSave={(date) => onPatch({ dueDate: date })}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<PMTypeCell
|
<PMTypeCell
|
||||||
value={draft.pm}
|
value={draft.pm}
|
||||||
|
|
@ -188,7 +186,6 @@ export function WoTableInlineRowCells({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<VendorCell
|
<VendorCell
|
||||||
vendorId={draft.vendorId}
|
vendorId={draft.vendorId}
|
||||||
|
|
@ -206,7 +203,6 @@ export function WoTableInlineRowCells({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<ApptTimeCell
|
<ApptTimeCell
|
||||||
apptTime={
|
apptTime={
|
||||||
|
|
@ -218,7 +214,6 @@ export function WoTableInlineRowCells({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<StatusCell
|
<StatusCell
|
||||||
wo={draftRow as never}
|
wo={draftRow as never}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export function WoTableRowCells({
|
||||||
pocName={row.pocName}
|
pocName={row.pocName}
|
||||||
pocPhone={row.pocPhone}
|
pocPhone={row.pocPhone}
|
||||||
pocNotes={row.pocNotes}
|
pocNotes={row.pocNotes}
|
||||||
|
additionalContacts={row.additionalContacts}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
q={search}
|
q={search}
|
||||||
onSave={(p) =>
|
onSave={(p) =>
|
||||||
|
|
@ -62,6 +63,9 @@ export function WoTableRowCells({
|
||||||
pocName: p.pocName,
|
pocName: p.pocName,
|
||||||
pocPhone: p.pocPhone,
|
pocPhone: p.pocPhone,
|
||||||
pocNotes: p.pocNotes,
|
pocNotes: p.pocNotes,
|
||||||
|
...(p.additionalContacts !== undefined
|
||||||
|
? { additionalContacts: p.additionalContacts }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
DLG_INPUT_CLS,
|
||||||
|
DLG_INPUT_STYLE,
|
||||||
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
|
import { isIncompleteAdditionalContact } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||||
|
import {
|
||||||
|
EMPTY_ADDITIONAL_CONTACT,
|
||||||
|
type WorkOrderAdditionalContact,
|
||||||
|
} from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
|
|
||||||
|
type PocContactFieldsProps = {
|
||||||
|
pocName: string;
|
||||||
|
pocPhone: string;
|
||||||
|
pocNotes: string;
|
||||||
|
additionalContacts: WorkOrderAdditionalContact[];
|
||||||
|
onPocNameChange: (value: string) => void;
|
||||||
|
onPocPhoneChange: (value: string) => void;
|
||||||
|
onPocNotesChange: (value: string) => void;
|
||||||
|
onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
|
||||||
|
helperText?: string;
|
||||||
|
showPrimaryErrors?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
|
||||||
|
return isIncompleteAdditionalContact(contact) && !contact.name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extraPhoneMissing(contact: WorkOrderAdditionalContact): boolean {
|
||||||
|
return isIncompleteAdditionalContact(contact) && !contact.phone.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContactAt(
|
||||||
|
contacts: WorkOrderAdditionalContact[],
|
||||||
|
index: number,
|
||||||
|
patch: Partial<WorkOrderAdditionalContact>,
|
||||||
|
): WorkOrderAdditionalContact[] {
|
||||||
|
return contacts.map((contact, contactIndex) =>
|
||||||
|
contactIndex === index ? { ...contact, ...patch } : contact,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdditionalPocCard({
|
||||||
|
contact,
|
||||||
|
index,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
contact: WorkOrderAdditionalContact;
|
||||||
|
index: number;
|
||||||
|
onChange: (patch: Partial<WorkOrderAdditionalContact>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const nameMissing = extraNameMissing(contact);
|
||||||
|
const phoneMissing = extraPhoneMissing(contact);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-2 rounded-lg p-3"
|
||||||
|
style={{ border: "1px solid var(--color-border)", background: "var(--color-bg-muted)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="text-[11px] font-semibold uppercase tracking-wide"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Point of contact {index + 2}
|
||||||
|
</Text>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove point of contact ${index + 2}`}
|
||||||
|
className="inline-flex shrink-0 items-center justify-center rounded p-1 transition-colors hover:bg-[var(--color-bg-surface)]"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={contact.name}
|
||||||
|
onChange={(event) => onChange({ name: event.target.value })}
|
||||||
|
placeholder="POC name"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={{
|
||||||
|
...DLG_INPUT_STYLE,
|
||||||
|
borderColor: nameMissing ? "var(--destructive)" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
|
||||||
|
Contact name is required
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={contact.phone}
|
||||||
|
onChange={(event) => onChange({ phone: event.target.value })}
|
||||||
|
placeholder="POC phone"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={{
|
||||||
|
...DLG_INPUT_STYLE,
|
||||||
|
borderColor: phoneMissing ? "var(--destructive)" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
|
||||||
|
Contact phone is required
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={contact.notes ?? ""}
|
||||||
|
onChange={(event) => onChange({ notes: event.target.value })}
|
||||||
|
placeholder="Notes…"
|
||||||
|
rows={2}
|
||||||
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PocContactFields({
|
||||||
|
pocName,
|
||||||
|
pocPhone,
|
||||||
|
pocNotes,
|
||||||
|
additionalContacts,
|
||||||
|
onPocNameChange,
|
||||||
|
onPocPhoneChange,
|
||||||
|
onPocNotesChange,
|
||||||
|
onAdditionalContactsChange,
|
||||||
|
helperText,
|
||||||
|
showPrimaryErrors = false,
|
||||||
|
}: PocContactFieldsProps) {
|
||||||
|
const nameMissing = showPrimaryErrors && !pocName.trim();
|
||||||
|
const phoneMissing = showPrimaryErrors && !pocPhone.trim();
|
||||||
|
|
||||||
|
const handleAddContact = () => {
|
||||||
|
onAdditionalContactsChange([...additionalContacts, { ...EMPTY_ADDITIONAL_CONTACT }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-1">
|
||||||
|
<FieldLabel>Point of Contact</FieldLabel>
|
||||||
|
{Boolean(helperText) && (
|
||||||
|
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{helperText}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={pocName}
|
||||||
|
onChange={(event) => onPocNameChange(event.target.value)}
|
||||||
|
placeholder="POC name"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={{
|
||||||
|
...DLG_INPUT_STYLE,
|
||||||
|
borderColor: nameMissing ? "var(--destructive)" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
|
||||||
|
Contact name is required
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={pocPhone}
|
||||||
|
onChange={(event) => onPocPhoneChange(event.target.value)}
|
||||||
|
placeholder="POC phone"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={{
|
||||||
|
...DLG_INPUT_STYLE,
|
||||||
|
borderColor: phoneMissing ? "var(--destructive)" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
|
||||||
|
Contact phone is required
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={pocNotes}
|
||||||
|
onChange={(event) => onPocNotesChange(event.target.value)}
|
||||||
|
placeholder="Notes…"
|
||||||
|
rows={2}
|
||||||
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{additionalContacts.map((contact, index) => (
|
||||||
|
<AdditionalPocCard
|
||||||
|
key={`additional-poc-${index}`}
|
||||||
|
contact={contact}
|
||||||
|
index={index}
|
||||||
|
onChange={(patch) =>
|
||||||
|
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
|
||||||
|
}
|
||||||
|
onRemove={() =>
|
||||||
|
onAdditionalContactsChange(
|
||||||
|
additionalContacts.filter((_, contactIndex) => contactIndex !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-3 min-h-0 gap-1.5 px-0 py-1 text-[12px]"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
onClick={handleAddContact}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="shrink-0" />
|
||||||
|
Add point of contact
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,18 @@ function pocLabel(draft: WorkOrderWizardDraft): string {
|
||||||
return draft.pocPhone ? `${draft.pocName} · ${draft.pocPhone}` : draft.pocName;
|
return draft.pocPhone ? `${draft.pocName} · ${draft.pocPhone}` : draft.pocName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAdditionalPocSummary(draft: WorkOrderWizardDraft): string {
|
||||||
|
const count = draft.additionalContacts.length;
|
||||||
|
if (count === 0) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
const names = draft.additionalContacts.map((contact) => contact.name.trim()).filter(Boolean);
|
||||||
|
if (names.length > 0) {
|
||||||
|
return names.join(", ");
|
||||||
|
}
|
||||||
|
return `${count} contact${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Builds the label/value rows for the wizard sidebar's live summary card. */
|
/** Builds the label/value rows for the wizard sidebar's live summary card. */
|
||||||
export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [string, string][] {
|
export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [string, string][] {
|
||||||
return [
|
return [
|
||||||
|
|
@ -36,6 +48,7 @@ export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [str
|
||||||
["Assigned to", draft.assigneeName || "—"],
|
["Assigned to", draft.assigneeName || "—"],
|
||||||
["Site", draft.siteCode || draft.locationName || "—"],
|
["Site", draft.siteCode || draft.locationName || "—"],
|
||||||
["POC", pocLabel(draft)],
|
["POC", pocLabel(draft)],
|
||||||
|
["Additional POCs", formatAdditionalPocSummary(draft)],
|
||||||
["Service", draft.pm || "—"],
|
["Service", draft.pm || "—"],
|
||||||
["Technician", draft.vendorName || "—"],
|
["Technician", draft.vendorName || "—"],
|
||||||
["Company", draft.vendorName || "—"],
|
["Company", draft.vendorName || "—"],
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { Text } from "@/components/ui/text";
|
import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import {
|
|
||||||
DLG_INPUT_CLS,
|
|
||||||
DLG_INPUT_STYLE,
|
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
|
||||||
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
|
||||||
|
|
||||||
type WizardStepLocationPocFieldsProps = {
|
type WizardStepLocationPocFieldsProps = {
|
||||||
draft: WorkOrderWizardDraft;
|
draft: WorkOrderWizardDraft;
|
||||||
|
|
@ -20,44 +15,17 @@ export function WizardStepLocationPocFields({
|
||||||
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
|
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PocContactFields
|
||||||
<WizLabel required>Point of contact</WizLabel>
|
pocName={draft.pocName}
|
||||||
<Text
|
pocPhone={draft.pocPhone}
|
||||||
as="p"
|
pocNotes={draft.pocNotes}
|
||||||
variant="description"
|
additionalContacts={draft.additionalContacts}
|
||||||
sx={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}
|
onPocNameChange={(value) => onUpdate("pocName", value)}
|
||||||
>
|
onPocPhoneChange={(value) => onUpdate("pocPhone", value)}
|
||||||
Auto-filled from records — edit if outdated
|
onPocNotesChange={(value) => onUpdate("pocNotes", value)}
|
||||||
</Text>
|
onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
helperText="Auto-filled from records — edit if outdated"
|
||||||
<input
|
showPrimaryErrors={showErrors && pocIncomplete}
|
||||||
value={draft.pocName}
|
/>
|
||||||
onChange={(e) => onUpdate("pocName", e.target.value)}
|
|
||||||
placeholder="POC name"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={draft.pocPhone}
|
|
||||||
onChange={(e) => onUpdate("pocPhone", e.target.value)}
|
|
||||||
placeholder="POC phone"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{Boolean(showErrors && pocIncomplete) && (
|
|
||||||
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
|
||||||
POC name and phone are required.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<textarea
|
|
||||||
value={draft.pocNotes}
|
|
||||||
onChange={(e) => onUpdate("pocNotes", e.target.value)}
|
|
||||||
placeholder="Notes…"
|
|
||||||
rows={2}
|
|
||||||
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export function WizardStepLocationSiteSelect({
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
102
src/domain/work-orders/mappers/additional-contacts-mapper.ts
Normal file
102
src/domain/work-orders/mappers/additional-contacts-mapper.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
|
|
||||||
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapContactItem(raw: unknown): WorkOrderAdditionalContact | null {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const name = readString(item, "name", "Name");
|
||||||
|
const phone = readString(item, "phone", "Phone");
|
||||||
|
const notes = readString(item, "notes", "Notes");
|
||||||
|
|
||||||
|
if (!name.trim() && !phone.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact: WorkOrderAdditionalContact = {
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (notes.trim()) {
|
||||||
|
contact.notes = notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads additional POCs from board/detail payloads; missing field yields []. */
|
||||||
|
export function readAdditionalContacts(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
): WorkOrderAdditionalContact[] {
|
||||||
|
for (const key of ["additionalContacts", "AdditionalContacts"]) {
|
||||||
|
const value = record[key];
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map(mapContactItem)
|
||||||
|
.filter((contact): contact is WorkOrderAdditionalContact => contact !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when exactly one of name/phone is filled (not persistable). */
|
||||||
|
export function isIncompleteAdditionalContact(contact: WorkOrderAdditionalContact): boolean {
|
||||||
|
const hasName = Boolean(contact.name.trim());
|
||||||
|
const hasPhone = Boolean(contact.phone.trim());
|
||||||
|
return hasName !== hasPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops empty and incomplete rows; returns undefined when nothing to persist. */
|
||||||
|
export function normalizeAdditionalContacts(
|
||||||
|
contacts: WorkOrderAdditionalContact[],
|
||||||
|
): WorkOrderAdditionalContact[] | undefined {
|
||||||
|
const out: WorkOrderAdditionalContact[] = [];
|
||||||
|
|
||||||
|
for (const raw of contacts) {
|
||||||
|
const name = raw.name.trim();
|
||||||
|
const phone = raw.phone.trim();
|
||||||
|
const notes = raw.notes?.trim();
|
||||||
|
|
||||||
|
if (!name && !phone) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !phone) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact: WorkOrderAdditionalContact = { name, phone };
|
||||||
|
|
||||||
|
if (notes) {
|
||||||
|
contact.notes = notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.length > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAdditionalContactsForPatch(
|
||||||
|
contacts: WorkOrderAdditionalContact[],
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(normalizeAdditionalContacts(contacts) ?? []);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||||
|
|
||||||
function trimOrUndefined(value: string | undefined): string | undefined {
|
function trimOrUndefined(value: string | undefined): string | undefined {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
|
|
@ -42,6 +43,11 @@ export function assignWizardServicePocFields(
|
||||||
payload.pocNotes = pocNotes;
|
payload.pocNotes = pocNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const additionalContacts = normalizeAdditionalContacts(draft.additionalContacts);
|
||||||
|
if (additionalContacts) {
|
||||||
|
payload.additionalContacts = additionalContacts;
|
||||||
|
}
|
||||||
|
|
||||||
const techPhone = trimOrUndefined(draft.techPhone);
|
const techPhone = trimOrUndefined(draft.techPhone);
|
||||||
|
|
||||||
if (techPhone) {
|
if (techPhone) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board";
|
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board";
|
||||||
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
|
import { serializeAdditionalContactsForPatch } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||||
import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
|
import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
|
||||||
|
|
||||||
export type BoardFieldOp = {
|
export type BoardFieldOp = {
|
||||||
|
|
@ -37,6 +39,11 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
||||||
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
||||||
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
||||||
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
|
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
|
||||||
|
{
|
||||||
|
key: "additionalContacts",
|
||||||
|
field: "additionalContacts",
|
||||||
|
toValue: (value) => serializeAdditionalContactsForPatch(value as WorkOrderAdditionalContact[]),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { readAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||||
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||||
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
|
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
|
||||||
import {
|
import {
|
||||||
|
|
@ -357,6 +358,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
pocName: readString(item, "pocName", "PocName", "POCName"),
|
pocName: readString(item, "pocName", "PocName", "POCName"),
|
||||||
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
|
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
|
||||||
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
|
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
|
||||||
|
additionalContacts: readAdditionalContacts(item),
|
||||||
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
|
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
|
||||||
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
||||||
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface WorkOrderAdditionalContact {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_ADDITIONAL_CONTACT: WorkOrderAdditionalContact = {
|
||||||
|
name: "",
|
||||||
|
phone: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { WorkOrderAuditEntry, WorkOrderComment } from "./work-order-detail";
|
import type { WorkOrderAuditEntry, WorkOrderComment } from "./work-order-detail";
|
||||||
|
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
||||||
import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
|
import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
|
||||||
|
|
||||||
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */
|
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */
|
||||||
|
|
@ -26,6 +27,7 @@ export interface WorkOrderBoardDetailInfoApi {
|
||||||
pocName?: string | null;
|
pocName?: string | null;
|
||||||
pocPhone?: string | null;
|
pocPhone?: string | null;
|
||||||
pocNotes?: string | null;
|
pocNotes?: string | null;
|
||||||
|
additionalContacts?: WorkOrderAdditionalContact[] | null;
|
||||||
lifecycleStatus?: number | null;
|
lifecycleStatus?: number | null;
|
||||||
lifecycleStatusLabel?: string | null;
|
lifecycleStatusLabel?: string | null;
|
||||||
legacyStatus?: string | null;
|
legacyStatus?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { DateRangeKey } from "./work-order-advanced-filters";
|
import type { DateRangeKey } from "./work-order-advanced-filters";
|
||||||
|
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
||||||
import type { WorkOrderTableRow } from "./work-order-table-row";
|
import type { WorkOrderTableRow } from "./work-order-table-row";
|
||||||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||||
|
|
||||||
|
|
@ -108,6 +109,7 @@ export interface WorkOrderBoardCreatePayload {
|
||||||
pocName?: string;
|
pocName?: string;
|
||||||
pocPhone?: string;
|
pocPhone?: string;
|
||||||
pocNotes?: string;
|
pocNotes?: string;
|
||||||
|
additionalContacts?: WorkOrderAdditionalContact[];
|
||||||
techPhone?: string;
|
techPhone?: string;
|
||||||
vendorNotes?: string;
|
vendorNotes?: string;
|
||||||
/** Severity 1–5 for Emergency/Reactive. */
|
/** Severity 1–5 for Emergency/Reactive. */
|
||||||
|
|
@ -142,6 +144,7 @@ export type WorkOrderBoardEditableField =
|
||||||
| "serviceNotes"
|
| "serviceNotes"
|
||||||
| "extraServices"
|
| "extraServices"
|
||||||
| "flagColor"
|
| "flagColor"
|
||||||
|
| "additionalContacts"
|
||||||
| "severity";
|
| "severity";
|
||||||
|
|
||||||
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
|
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
|
||||||
|
|
@ -159,6 +162,7 @@ export interface WorkOrderBoardRowApi {
|
||||||
pocName?: string | null;
|
pocName?: string | null;
|
||||||
pocPhone?: string | null;
|
pocPhone?: string | null;
|
||||||
pocNotes?: string | null;
|
pocNotes?: string | null;
|
||||||
|
additionalContacts?: WorkOrderAdditionalContact[] | null;
|
||||||
lifecycleStatus?: number | null;
|
lifecycleStatus?: number | null;
|
||||||
lifecycleStatusLabel?: string | null;
|
lifecycleStatusLabel?: string | null;
|
||||||
legacyStatus?: string | null;
|
legacyStatus?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
||||||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||||
import type { WorkOrderSeverity } from "./work-order-severity";
|
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||||
import type { UpliftSummary } from "./work-order-uplift";
|
import type { UpliftSummary } from "./work-order-uplift";
|
||||||
|
|
@ -46,6 +47,7 @@ export interface WorkOrderTableRow {
|
||||||
pocName: string;
|
pocName: string;
|
||||||
pocPhone: string;
|
pocPhone: string;
|
||||||
pocNotes: string;
|
pocNotes: string;
|
||||||
|
additionalContacts: WorkOrderAdditionalContact[];
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
rescheduleCount: number;
|
rescheduleCount: number;
|
||||||
carriedOver: number;
|
carriedOver: number;
|
||||||
|
|
@ -94,6 +96,7 @@ export interface WorkOrderTableDraft {
|
||||||
pocName: string;
|
pocName: string;
|
||||||
pocPhone: string;
|
pocPhone: string;
|
||||||
pocNotes: string;
|
pocNotes: string;
|
||||||
|
additionalContacts: WorkOrderAdditionalContact[];
|
||||||
scheduleWeekOnly: boolean;
|
scheduleWeekOnly: boolean;
|
||||||
targetWeek: string;
|
targetWeek: string;
|
||||||
/** Manual Add-On override when schedule is empty at inline create. */
|
/** Manual Add-On override when schedule is empty at inline create. */
|
||||||
|
|
@ -124,6 +127,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
scheduleWeekOnly: false,
|
scheduleWeekOnly: false,
|
||||||
targetWeek: "",
|
targetWeek: "",
|
||||||
isAddOnOverride: false,
|
isAddOnOverride: false,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
||||||
import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity";
|
import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity";
|
||||||
|
|
||||||
export type { WorkOrderSeverity } from "./work-order-severity";
|
export type { WorkOrderSeverity } from "./work-order-severity";
|
||||||
|
|
@ -50,6 +51,8 @@ export interface WorkOrderWizardDraft {
|
||||||
pocName: string;
|
pocName: string;
|
||||||
pocPhone: string;
|
pocPhone: string;
|
||||||
pocNotes: string;
|
pocNotes: string;
|
||||||
|
/** Secondary POCs beyond primary pocName/pocPhone/pocNotes. */
|
||||||
|
additionalContacts: WorkOrderAdditionalContact[];
|
||||||
scheduleWeekOnly: boolean;
|
scheduleWeekOnly: boolean;
|
||||||
/** Monday ISO when week-only; preferred over deriving from scheduledOn (inline create). */
|
/** Monday ISO when week-only; preferred over deriving from scheduledOn (inline create). */
|
||||||
targetWeek?: string;
|
targetWeek?: string;
|
||||||
|
|
@ -89,6 +92,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
scheduleWeekOnly: false,
|
scheduleWeekOnly: false,
|
||||||
targetWeek: "",
|
targetWeek: "",
|
||||||
scheduleEnd: "",
|
scheduleEnd: "",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
import type {
|
import type {
|
||||||
WorkOrderTablePatch,
|
WorkOrderTablePatch,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
|
|
@ -5,8 +6,9 @@ import type {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields compared for dirty state and included in Save patch.
|
* Fields compared for dirty state and included in Save patch.
|
||||||
* Only keys that map to board PATCH (or required companions like dispatcherName / vendor labels).
|
* Only keys that map to board PATCH (or required companions like dispatcherName).
|
||||||
* POC, locationId, and tech phone are not board-editable — exclude so Save cannot no-op.
|
* Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op.
|
||||||
|
* additionalContacts is board-editable (SH-218 PATCH field).
|
||||||
*/
|
*/
|
||||||
export const SLIDE_OVER_EDIT_KEYS = [
|
export const SLIDE_OVER_EDIT_KEYS = [
|
||||||
"woNumber",
|
"woNumber",
|
||||||
|
|
@ -23,6 +25,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
||||||
"pm",
|
"pm",
|
||||||
"serviceNotes",
|
"serviceNotes",
|
||||||
"extraServices",
|
"extraServices",
|
||||||
|
"additionalContacts",
|
||||||
"vendorId",
|
"vendorId",
|
||||||
// company/tech are display companions; board persists vendor via vendorId only
|
// company/tech are display companions; board persists vendor via vendorId only
|
||||||
"status",
|
"status",
|
||||||
|
|
@ -37,6 +40,23 @@ function sameExtraServices(a: string[] | undefined, b: string[] | undefined): bo
|
||||||
return left.every((value, index) => value === right[index]);
|
return left.every((value, index) => value === right[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameAdditionalContacts(
|
||||||
|
a: WorkOrderAdditionalContact[] | undefined,
|
||||||
|
b: WorkOrderAdditionalContact[] | undefined,
|
||||||
|
): boolean {
|
||||||
|
const left = a ?? [];
|
||||||
|
const right = b ?? [];
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
return left.every((contact, index) => {
|
||||||
|
const other = right[index];
|
||||||
|
return (
|
||||||
|
contact.name === other.name &&
|
||||||
|
contact.phone === other.phone &&
|
||||||
|
(contact.notes ?? "") === (other.notes ?? "")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function isSlideOverDraftDirty(
|
export function isSlideOverDraftDirty(
|
||||||
draft: WorkOrderTableRow,
|
draft: WorkOrderTableRow,
|
||||||
baseline: WorkOrderTableRow,
|
baseline: WorkOrderTableRow,
|
||||||
|
|
@ -46,6 +66,12 @@ export function isSlideOverDraftDirty(
|
||||||
if (!sameExtraServices(draft.extraServices, baseline.extraServices)) return true;
|
if (!sameExtraServices(draft.extraServices, baseline.extraServices)) return true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (key === "additionalContacts") {
|
||||||
|
if (!sameAdditionalContacts(draft.additionalContacts, baseline.additionalContacts)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (draft[key] !== baseline[key]) return true;
|
if (draft[key] !== baseline[key]) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -64,6 +90,12 @@ export function buildSlideOverPatch(
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (key === "additionalContacts") {
|
||||||
|
if (!sameAdditionalContacts(draft.additionalContacts, baseline.additionalContacts)) {
|
||||||
|
patch.additionalContacts = draft.additionalContacts;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (draft[key] !== baseline[key]) {
|
if (draft[key] !== baseline[key]) {
|
||||||
(patch as Record<EditKey, WorkOrderTableRow[EditKey]>)[key] = draft[key];
|
(patch as Record<EditKey, WorkOrderTableRow[EditKey]>)[key] = draft[key];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
pocName: "Amazon Rep",
|
pocName: "Amazon Rep",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ function baseRow(): WorkOrderTableRow {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||||
|
import { buildSlideOverPatch } from "@/domain/work-orders/utils/slide-over-draft";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
|
||||||
|
SiteCell: ({
|
||||||
|
additionalContacts,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
additionalContacts?: { name: string; phone: string }[];
|
||||||
|
onSave: (patch: {
|
||||||
|
site: string;
|
||||||
|
location: string;
|
||||||
|
locationId: string;
|
||||||
|
pocName: string;
|
||||||
|
pocPhone: string;
|
||||||
|
pocNotes: string;
|
||||||
|
additionalContacts?: { name: string; phone: string }[];
|
||||||
|
}) => void;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onSave({
|
||||||
|
site: "SEA1",
|
||||||
|
location: "Seattle",
|
||||||
|
locationId: "loc-1",
|
||||||
|
pocName: "Pat",
|
||||||
|
pocPhone: "111",
|
||||||
|
pocNotes: "",
|
||||||
|
additionalContacts: [{ name: "Backup", phone: "555-0101" }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
save site {additionalContacts?.length ?? 0}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
|
||||||
|
() => ({
|
||||||
|
EditableWONumberCell: () => null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||||
|
TypeCell: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||||
|
AssignedToCell: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
|
||||||
|
DateCell: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
|
||||||
|
ApptTimeCell: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
|
||||||
|
PMTypeCell: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
|
||||||
|
VendorCell: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
|
||||||
|
StatusCell: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
woNumber: "WO-42",
|
||||||
|
site: "SEA1",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Dispatcher",
|
||||||
|
scheduledOn: "2026-07-20",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "07/20",
|
||||||
|
dayLabel: "Mon",
|
||||||
|
location: "Seattle",
|
||||||
|
locationId: "loc-1",
|
||||||
|
pm: "HVAC",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
company: "Vendor Co",
|
||||||
|
tech: "Tech",
|
||||||
|
techPhone: "",
|
||||||
|
vendorId: "v1",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Scheduled",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
isAddOn: false,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "v1",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SlideOverInfoTabEditView additional contacts", () => {
|
||||||
|
it("passes additionalContacts into SiteCell and persists them on save", () => {
|
||||||
|
const onDraftChange = vi.fn();
|
||||||
|
const contacts = [{ name: "Existing", phone: "555-0000" }];
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverInfoTabEditView
|
||||||
|
draft={baseRow({ additionalContacts: contacts })}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
allOrders={[]}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /save site 1/i })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save site 1/i }));
|
||||||
|
|
||||||
|
expect(onDraftChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
additionalContacts: [{ name: "Backup", phone: "555-0101" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes additionalContacts in the slide-over board PATCH ops", () => {
|
||||||
|
const baseline = baseRow();
|
||||||
|
const draft = baseRow({
|
||||||
|
additionalContacts: [{ name: "Backup", phone: "555-0101", notes: "Gate" }],
|
||||||
|
});
|
||||||
|
const patch = buildSlideOverPatch(draft, baseline);
|
||||||
|
|
||||||
|
expect(expandTablePatchToBoardFieldOps(patch)).toEqual([
|
||||||
|
{
|
||||||
|
field: "additionalContacts",
|
||||||
|
value: JSON.stringify([{ name: "Backup", phone: "555-0101", notes: "Gate" }]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -50,6 +50,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
rowVersion: "",
|
rowVersion: "",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
additionalContacts: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
pocName: "Amazon Rep",
|
pocName: "Amazon Rep",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ function renderSiteDialogState() {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
sites: SITES,
|
sites: SITES,
|
||||||
createMode: true,
|
createMode: true,
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
rowVersion: "",
|
rowVersion: "",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
additionalContacts: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const unscheduledRow: WorkOrderTableRow = {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "2026-07-15",
|
dueDate: "2026-07-15",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ const baseRow: WorkOrderTableRow = {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ const canceledRow: WorkOrderTableRow = {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isIncompleteAdditionalContact,
|
||||||
|
normalizeAdditionalContacts,
|
||||||
|
readAdditionalContacts,
|
||||||
|
serializeAdditionalContactsForPatch,
|
||||||
|
} from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||||
|
|
||||||
|
describe("readAdditionalContacts", () => {
|
||||||
|
it("returns [] when field is missing", () => {
|
||||||
|
expect(readAdditionalContacts({ id: 1 })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps camelCase and PascalCase arrays", () => {
|
||||||
|
expect(
|
||||||
|
readAdditionalContacts({
|
||||||
|
additionalContacts: [{ name: "Alex", phone: "555-0100", notes: "Gate code" }],
|
||||||
|
}),
|
||||||
|
).toEqual([{ name: "Alex", phone: "555-0100", notes: "Gate code" }]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readAdditionalContacts({
|
||||||
|
AdditionalContacts: [{ Name: "Sam", Phone: "555-0200" }],
|
||||||
|
}),
|
||||||
|
).toEqual([{ name: "Sam", phone: "555-0200" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty rows", () => {
|
||||||
|
expect(
|
||||||
|
readAdditionalContacts({
|
||||||
|
additionalContacts: [
|
||||||
|
{ name: "", phone: "" },
|
||||||
|
{ name: "Pat", phone: "111" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([{ name: "Pat", phone: "111" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeAdditionalContacts", () => {
|
||||||
|
it("trims and omits empty notes", () => {
|
||||||
|
expect(
|
||||||
|
normalizeAdditionalContacts([
|
||||||
|
{ name: " Alex ", phone: " 555 ", notes: " " },
|
||||||
|
{ name: "", phone: "" },
|
||||||
|
]),
|
||||||
|
).toEqual([{ name: "Alex", phone: "555" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when all rows are empty", () => {
|
||||||
|
expect(normalizeAdditionalContacts([{ name: "", phone: "" }])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops name-only and phone-only rows", () => {
|
||||||
|
expect(
|
||||||
|
normalizeAdditionalContacts([
|
||||||
|
{ name: "Alex", phone: "" },
|
||||||
|
{ name: "", phone: "555-0100" },
|
||||||
|
{ name: "Pat", phone: "111" },
|
||||||
|
]),
|
||||||
|
).toEqual([{ name: "Pat", phone: "111" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isIncompleteAdditionalContact", () => {
|
||||||
|
it("is true when exactly one of name or phone is filled", () => {
|
||||||
|
expect(isIncompleteAdditionalContact({ name: "Alex", phone: "" })).toBe(true);
|
||||||
|
expect(isIncompleteAdditionalContact({ name: "", phone: "555" })).toBe(true);
|
||||||
|
expect(isIncompleteAdditionalContact({ name: "Alex", phone: "555" })).toBe(false);
|
||||||
|
expect(isIncompleteAdditionalContact({ name: "", phone: "" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serializeAdditionalContactsForPatch", () => {
|
||||||
|
it("JSON-stringifies normalized contacts for board PATCH", () => {
|
||||||
|
expect(
|
||||||
|
serializeAdditionalContactsForPatch([
|
||||||
|
{ name: "Alex", phone: "555-0100" },
|
||||||
|
{ name: "", phone: "" },
|
||||||
|
]),
|
||||||
|
).toBe('[{"name":"Alex","phone":"555-0100"}]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes empty array when nothing to persist", () => {
|
||||||
|
expect(serializeAdditionalContactsForPatch([])).toBe("[]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -41,6 +41,10 @@ const SAMPLE_DETAIL = {
|
||||||
pm: "HVAC PM",
|
pm: "HVAC PM",
|
||||||
serviceNotes: "Check filters",
|
serviceNotes: "Check filters",
|
||||||
extraServices: ["Filter Change"],
|
extraServices: ["Filter Change"],
|
||||||
|
additionalContacts: [
|
||||||
|
{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" },
|
||||||
|
{ name: "Security Desk", phone: "+1 555-0102" },
|
||||||
|
],
|
||||||
docStatus: 2,
|
docStatus: 2,
|
||||||
flagColor: "#EF4444",
|
flagColor: "#EF4444",
|
||||||
primaryDispatchId: 789,
|
primaryDispatchId: 789,
|
||||||
|
|
@ -124,6 +128,10 @@ describe("mapWorkOrderBoardDetail", () => {
|
||||||
expect(detail.info.tech).toBe("Mike Tech");
|
expect(detail.info.tech).toBe("Mike Tech");
|
||||||
expect(detail.info.pm).toBe("HVAC PM");
|
expect(detail.info.pm).toBe("HVAC PM");
|
||||||
expect(detail.info.location).toBe("Main Warehouse");
|
expect(detail.info.location).toBe("Main Warehouse");
|
||||||
|
expect(detail.info.additionalContacts).toEqual([
|
||||||
|
{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" },
|
||||||
|
{ name: "Security Desk", phone: "+1 555-0102" },
|
||||||
|
]);
|
||||||
|
|
||||||
expect(detail.completion.docStatus).toBe("No");
|
expect(detail.completion.docStatus).toBe("No");
|
||||||
expect(detail.completion.template?.name).toBe("HVAC PM Completion");
|
expect(detail.completion.template?.name).toBe("HVAC PM Completion");
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
|
|
||||||
pocNotes: "Call before arrival",
|
pocNotes: "Call before arrival",
|
||||||
|
|
||||||
|
additionalContacts: [{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" }],
|
||||||
|
|
||||||
techPhone: "+1 555-0199",
|
techPhone: "+1 555-0199",
|
||||||
|
|
||||||
vendorNotes: "Gate code 4421",
|
vendorNotes: "Gate code 4421",
|
||||||
|
|
@ -204,12 +206,12 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
vendorNotes: "Gate code 4421",
|
vendorNotes: "Gate code 4421",
|
||||||
|
|
||||||
locationId: 12,
|
locationId: 12,
|
||||||
|
|
||||||
|
additionalContacts: [{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.trade).toBeUndefined();
|
expect(payload.trade).toBeUndefined();
|
||||||
|
|
||||||
expect(payload.extraServices).toBeUndefined();
|
|
||||||
|
|
||||||
expect(payload.description).toBeUndefined();
|
expect(payload.description).toBeUndefined();
|
||||||
|
|
||||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||||
|
|
@ -376,7 +378,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
expect(payload.vendorNotes).toBe("Also not in description");
|
expect(payload.vendorNotes).toBe("Also not in description");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps service notes as structured fields and omits extraServices from wizard create", () => {
|
it("maps service notes as structured fields and omits extraServices on create", () => {
|
||||||
const payload = mapWizardDraftToBoardCreatePayload({
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,19 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits additionalContacts as serialized JSON for board PATCH", () => {
|
||||||
|
expect(
|
||||||
|
expandTablePatchToBoardFieldOps({
|
||||||
|
additionalContacts: [{ name: "Alex", phone: "555-0100", notes: "Gate" }],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
field: "additionalContacts",
|
||||||
|
value: JSON.stringify([{ name: "Alex", phone: "555-0100", notes: "Gate" }]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores local-only companion keys when expanding vendor/site", () => {
|
it("ignores local-only companion keys when expanding vendor/site", () => {
|
||||||
expect(
|
expect(
|
||||||
expandTablePatchToBoardFieldOps({
|
expandTablePatchToBoardFieldOps({
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
|
||||||
expect(row.serviceNotes).toBe("service notes");
|
expect(row.serviceNotes).toBe("service notes");
|
||||||
expect(row.extraServices).toEqual(["Filter change", "Coil clean"]);
|
expect(row.extraServices).toEqual(["Filter change", "Coil clean"]);
|
||||||
expect(row.docStatus).toBe("Yes");
|
expect(row.docStatus).toBe("Yes");
|
||||||
|
expect(row.additionalContacts).toEqual([]);
|
||||||
expect(row.dayGroup).toBe("07/15");
|
expect(row.dayGroup).toBe("07/15");
|
||||||
expect(row.flagColor).toBe("#EF4444");
|
expect(row.flagColor).toBe("#EF4444");
|
||||||
});
|
});
|
||||||
|
|
@ -175,6 +176,15 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
|
||||||
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
|
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps additionalContacts from board rows", () => {
|
||||||
|
expect(
|
||||||
|
mapWorkOrderTableRow({
|
||||||
|
id: 1,
|
||||||
|
additionalContacts: [{ name: "Alex", phone: "555-0100" }],
|
||||||
|
}).additionalContacts,
|
||||||
|
).toEqual([{ name: "Alex", phone: "555-0100" }]);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps uplift summary and pending flag from API payload", () => {
|
it("maps uplift summary and pending flag from API payload", () => {
|
||||||
const row = mapWorkOrderTableRow({
|
const row = mapWorkOrderTableRow({
|
||||||
id: 303,
|
id: 303,
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ const BASE_ROW: WorkOrderTableRow = {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "2026-07-20",
|
dueDate: "2026-07-20",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
@ -63,6 +64,7 @@ describe("slide-over-draft", () => {
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
|
||||||
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
|
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
|
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
|
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
|
||||||
|
|
@ -112,4 +114,14 @@ describe("slide-over-draft", () => {
|
||||||
extraServices: ["HVAC"],
|
extraServices: ["HVAC"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tracks ordered additionalContacts for dirty state and patch (SH-218)", () => {
|
||||||
|
const row = baseRow();
|
||||||
|
const contacts = [{ name: "Backup", phone: "555-0101", notes: "Gate" }];
|
||||||
|
const draft = baseRow({ additionalContacts: contacts });
|
||||||
|
|
||||||
|
expect(isSlideOverDraftDirty(draft, row)).toBe(true);
|
||||||
|
expect(buildSlideOverPatch(draft, row)).toEqual({ additionalContacts: contacts });
|
||||||
|
expect(isSlideOverDraftDirty(baseRow({ additionalContacts: [] }), row)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "2026-07-20",
|
dueDate: "2026-07-20",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "2026-06-30",
|
dueDate: "2026-06-30",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue