mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 10:23:11 +00:00
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:
parent
0d609124d1
commit
d93ae683c7
5 changed files with 172 additions and 66 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`.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue