fix(work-orders): restore additionalContacts contract and name+phone extras (SH-218)

Document the backend #66 producer, drop incomplete extras from persist, and show the same required-field errors on POC 2+.
This commit is contained in:
arthur.bassi 2026-08-18 10:03:14 -03:00
parent 0d609124d1
commit d93ae683c7
5 changed files with 172 additions and 66 deletions

View 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`.

View file

@ -6,6 +6,7 @@ import {
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,
@ -24,6 +25,14 @@ type PocContactFieldsProps = {
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,
@ -34,6 +43,87 @@ function updateContactAt(
);
}
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,
@ -53,12 +143,6 @@ export function PocContactFields({
onAdditionalContactsChange([...additionalContacts, { ...EMPTY_ADDITIONAL_CONTACT }]);
};
const handleRemoveContact = (index: number) => {
onAdditionalContactsChange(
additionalContacts.filter((_, contactIndex) => contactIndex !== index),
);
};
return (
<div className="pt-1">
<FieldLabel>Point of Contact</FieldLabel>
@ -109,66 +193,19 @@ export function PocContactFields({
</div>
{additionalContacts.map((contact, index) => (
<div
<AdditionalPocCard
key={`additional-poc-${index}`}
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={() => handleRemoveContact(index)}
>
<Trash2 size={14} />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<input
value={contact.name}
onChange={(event) =>
onAdditionalContactsChange(
updateContactAt(additionalContacts, index, { name: event.target.value }),
)
}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
<input
value={contact.phone}
onChange={(event) =>
onAdditionalContactsChange(
updateContactAt(additionalContacts, index, { phone: event.target.value }),
)
}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
<textarea
value={contact.notes ?? ""}
onChange={(event) =>
onAdditionalContactsChange(
updateContactAt(additionalContacts, index, { notes: event.target.value }),
)
}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
contact={contact}
index={index}
onChange={(patch) =>
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
}
onRemove={() =>
onAdditionalContactsChange(
additionalContacts.filter((_, contactIndex) => contactIndex !== index),
)
}
/>
))}
<Button

View file

@ -57,7 +57,14 @@ export function readAdditionalContacts(
return [];
}
/** Drops empty rows and trims strings; returns undefined when nothing to persist. */
/** 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 {
@ -72,6 +79,10 @@ export function normalizeAdditionalContacts(
continue;
}
if (!name || !phone) {
continue;
}
const contact: WorkOrderAdditionalContact = { name, phone };
if (notes) {

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
isIncompleteAdditionalContact,
normalizeAdditionalContacts,
readAdditionalContacts,
serializeAdditionalContactsForPatch,
@ -49,6 +50,25 @@ describe("normalizeAdditionalContacts", () => {
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", () => {

View file

@ -41,6 +41,10 @@ const SAMPLE_DETAIL = {
pm: "HVAC PM",
serviceNotes: "Check filters",
extraServices: ["Filter Change"],
additionalContacts: [
{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" },
{ name: "Security Desk", phone: "+1 555-0102" },
],
docStatus: 2,
flagColor: "#EF4444",
primaryDispatchId: 789,
@ -124,6 +128,10 @@ describe("mapWorkOrderBoardDetail", () => {
expect(detail.info.tech).toBe("Mike Tech");
expect(detail.info.pm).toBe("HVAC PM");
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.template?.name).toBe("HVAC PM Completion");