mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 08:22:06 +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,
|
DLG_INPUT_STYLE,
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
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 { isIncompleteAdditionalContact } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||||
import {
|
import {
|
||||||
EMPTY_ADDITIONAL_CONTACT,
|
EMPTY_ADDITIONAL_CONTACT,
|
||||||
type WorkOrderAdditionalContact,
|
type WorkOrderAdditionalContact,
|
||||||
|
|
@ -24,6 +25,14 @@ type PocContactFieldsProps = {
|
||||||
showPrimaryErrors?: boolean;
|
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(
|
function updateContactAt(
|
||||||
contacts: WorkOrderAdditionalContact[],
|
contacts: WorkOrderAdditionalContact[],
|
||||||
index: number,
|
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({
|
export function PocContactFields({
|
||||||
pocName,
|
pocName,
|
||||||
pocPhone,
|
pocPhone,
|
||||||
|
|
@ -53,12 +143,6 @@ export function PocContactFields({
|
||||||
onAdditionalContactsChange([...additionalContacts, { ...EMPTY_ADDITIONAL_CONTACT }]);
|
onAdditionalContactsChange([...additionalContacts, { ...EMPTY_ADDITIONAL_CONTACT }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveContact = (index: number) => {
|
|
||||||
onAdditionalContactsChange(
|
|
||||||
additionalContacts.filter((_, contactIndex) => contactIndex !== index),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<FieldLabel>Point of Contact</FieldLabel>
|
<FieldLabel>Point of Contact</FieldLabel>
|
||||||
|
|
@ -109,66 +193,19 @@ export function PocContactFields({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{additionalContacts.map((contact, index) => (
|
{additionalContacts.map((contact, index) => (
|
||||||
<div
|
<AdditionalPocCard
|
||||||
key={`additional-poc-${index}`}
|
key={`additional-poc-${index}`}
|
||||||
className="mt-3 space-y-2 rounded-lg p-3"
|
contact={contact}
|
||||||
style={{ border: "1px solid var(--color-border)", background: "var(--color-bg-muted)" }}
|
index={index}
|
||||||
>
|
onChange={(patch) =>
|
||||||
<div className="flex items-center justify-between gap-2">
|
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
|
||||||
<Text
|
}
|
||||||
as="p"
|
onRemove={() =>
|
||||||
className="text-[11px] font-semibold uppercase tracking-wide"
|
onAdditionalContactsChange(
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
additionalContacts.filter((_, contactIndex) => contactIndex !== index),
|
||||||
>
|
)
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,14 @@ export function readAdditionalContacts(
|
||||||
return [];
|
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(
|
export function normalizeAdditionalContacts(
|
||||||
contacts: WorkOrderAdditionalContact[],
|
contacts: WorkOrderAdditionalContact[],
|
||||||
): WorkOrderAdditionalContact[] | undefined {
|
): WorkOrderAdditionalContact[] | undefined {
|
||||||
|
|
@ -72,6 +79,10 @@ export function normalizeAdditionalContacts(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!name || !phone) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const contact: WorkOrderAdditionalContact = { name, phone };
|
const contact: WorkOrderAdditionalContact = { name, phone };
|
||||||
|
|
||||||
if (notes) {
|
if (notes) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
isIncompleteAdditionalContact,
|
||||||
normalizeAdditionalContacts,
|
normalizeAdditionalContacts,
|
||||||
readAdditionalContacts,
|
readAdditionalContacts,
|
||||||
serializeAdditionalContactsForPatch,
|
serializeAdditionalContactsForPatch,
|
||||||
|
|
@ -49,6 +50,25 @@ describe("normalizeAdditionalContacts", () => {
|
||||||
it("returns undefined when all rows are empty", () => {
|
it("returns undefined when all rows are empty", () => {
|
||||||
expect(normalizeAdditionalContacts([{ name: "", phone: "" }])).toBeUndefined();
|
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", () => {
|
describe("serializeAdditionalContactsForPatch", () => {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue