mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 10:23:11 +00:00
Merge branch 'dev' into fix/ab/sh-341-generate-completes-work-order
This commit is contained in:
commit
33a979c79d
10 changed files with 216 additions and 33 deletions
|
|
@ -22,6 +22,8 @@ type EditableWONumberCellProps = {
|
|||
onSave: (v: string) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
locked?: boolean;
|
||||
/** Empty-state label for create flows; renders with a persistent required asterisk. */
|
||||
requiredPlaceholder?: string;
|
||||
};
|
||||
|
||||
async function resolveDuplicateWoNumber(
|
||||
|
|
@ -34,6 +36,28 @@ async function resolveDuplicateWoNumber(
|
|||
return remote && String(remote.id) !== String(currentId) ? remote : undefined;
|
||||
}
|
||||
|
||||
function EmptyWoNumberLabel({
|
||||
value,
|
||||
q,
|
||||
requiredPlaceholder,
|
||||
}: {
|
||||
value: string;
|
||||
q?: string;
|
||||
requiredPlaceholder?: string;
|
||||
}) {
|
||||
if (value) {
|
||||
return <HighlightText text={value} q={q} />;
|
||||
}
|
||||
if (!requiredPlaceholder) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<span className="font-sans text-[13px] font-normal">
|
||||
{requiredPlaceholder} <span style={{ color: "var(--destructive)" }}>*</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableWONumberCell({
|
||||
value,
|
||||
rescheduleCount,
|
||||
|
|
@ -49,6 +73,7 @@ export function EditableWONumberCell({
|
|||
onSave,
|
||||
onDuplicateFound,
|
||||
locked = false,
|
||||
requiredPlaceholder,
|
||||
}: EditableWONumberCellProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
|
@ -162,7 +187,7 @@ export function EditableWONumberCell({
|
|||
padding: "2px 4px",
|
||||
}}
|
||||
>
|
||||
{value ? <HighlightText text={value} q={q} /> : "-"}
|
||||
<EmptyWoNumberLabel value={value} q={q} requiredPlaceholder={requiredPlaceholder} />
|
||||
</span>
|
||||
)}
|
||||
<AddOnPill when={isAddOn} />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ type SiteDialogPocSectionProps = {
|
|||
pocNotes: string;
|
||||
additionalContacts: WorkOrderAdditionalContact[];
|
||||
showPrimaryErrors?: boolean;
|
||||
primaryRequired?: boolean;
|
||||
onPocNameChange: (value: string) => void;
|
||||
onPocPhoneChange: (value: string) => void;
|
||||
onPocNotesChange: (value: string) => void;
|
||||
|
|
@ -19,6 +20,7 @@ export function SiteDialogPocSection({
|
|||
pocNotes,
|
||||
additionalContacts,
|
||||
showPrimaryErrors = false,
|
||||
primaryRequired = false,
|
||||
onPocNameChange,
|
||||
onPocPhoneChange,
|
||||
onPocNotesChange,
|
||||
|
|
@ -31,6 +33,7 @@ export function SiteDialogPocSection({
|
|||
pocNotes={pocNotes}
|
||||
additionalContacts={additionalContacts}
|
||||
showPrimaryErrors={showPrimaryErrors}
|
||||
primaryRequired={primaryRequired}
|
||||
onPocNameChange={onPocNameChange}
|
||||
onPocPhoneChange={onPocPhoneChange}
|
||||
onPocNotesChange={onPocNotesChange}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export function SiteDialog({
|
|||
|
||||
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
|
||||
<div>
|
||||
<FieldLabel>Site</FieldLabel>
|
||||
<FieldLabel required={createMode}>Site</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={s.locId}
|
||||
placeholder="Select site …"
|
||||
|
|
@ -130,6 +130,7 @@ export function SiteDialog({
|
|||
pocNotes={s.notes}
|
||||
additionalContacts={s.extraContacts}
|
||||
showPrimaryErrors={createMode && s.showErrors}
|
||||
primaryRequired={createMode}
|
||||
onPocNameChange={s.setPn}
|
||||
onPocPhoneChange={s.setPp}
|
||||
onPocNotesChange={s.setNotes}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export function WoTableInlineIdentityCells({
|
|||
allOrders={allRows as never[]}
|
||||
onSave={(v) => onPatch({ woNumber: v })}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
requiredPlaceholder="Insert WO #"
|
||||
/>
|
||||
<AddOnOverrideToggle
|
||||
checked={draft.isAddOnOverride}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function WoTableInlineRowActions({
|
|||
onCancel,
|
||||
}: WoTableInlineRowActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex items-center gap-0.5" style={{ minHeight: 34 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RefObject } from "react";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
|
|
@ -21,6 +21,7 @@ import { StatusCell } from "./cells/status-cell";
|
|||
import { TypeCell } from "./cells/type-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import { InlineCreateField } from "./inline-create-field";
|
||||
import { toInlineDraftRow } from "./to-inline-draft-row";
|
||||
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
|
||||
|
||||
|
|
@ -43,6 +44,17 @@ type WoTableInlineRowCellsProps = {
|
|||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
/** Matches the identity cells: top-aligned so field boxes and error helpers line up. */
|
||||
const INLINE_FIELD_TD_STYLE = { padding: "8px 6px 0", verticalAlign: "top" } as const;
|
||||
|
||||
function InlineFieldCell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<td className="group/cell" style={INLINE_FIELD_TD_STYLE}>
|
||||
<InlineCreateField>{children}</InlineCreateField>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineTypeSeverityCell({
|
||||
draft,
|
||||
onPatch,
|
||||
|
|
@ -51,20 +63,22 @@ function InlineTypeSeverityCell({
|
|||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
}) {
|
||||
return (
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<InlineFieldCell>
|
||||
<TypeCell
|
||||
value={draft.type}
|
||||
severity={draft.severity}
|
||||
onSave={(t, sev) => onPatch({ type: t, severity: sev })}
|
||||
/>
|
||||
</td>
|
||||
</InlineFieldCell>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineMutedDashCell() {
|
||||
return (
|
||||
<td style={{ padding: "0 14px" }}>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||
<td style={INLINE_FIELD_TD_STYLE}>
|
||||
<div className="flex items-center" style={{ minHeight: 34 }}>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
|
@ -81,13 +95,12 @@ function InlineScheduleOnCell({
|
|||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
return (
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<InlineFieldCell>
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={weekDefault ? "week" : "date"}
|
||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onSave={(date) =>
|
||||
|
|
@ -142,7 +155,7 @@ function InlineScheduleOnCell({
|
|||
: undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</InlineFieldCell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -184,26 +197,25 @@ export function WoTableInlineRowCells({
|
|||
|
||||
<InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<InlineFieldCell>
|
||||
<AssignedToCell
|
||||
dispatcherId={draft.dispatcherId}
|
||||
users={users}
|
||||
highlight={!draft.dispatcherId}
|
||||
onSave={(id) => {
|
||||
onPatch({ dispatcherId: id, status: draft.status || "Incomplete" });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</InlineFieldCell>
|
||||
<InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} />
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<InlineFieldCell>
|
||||
<DueDateCell
|
||||
type={draft.type}
|
||||
value={draft.dueDate}
|
||||
onSave={(date) => onPatch({ dueDate: date })}
|
||||
/>
|
||||
</td>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
</InlineFieldCell>
|
||||
<InlineFieldCell>
|
||||
<PMTypeCell
|
||||
value={draft.pm}
|
||||
pmTypes={pmTypes}
|
||||
|
|
@ -221,8 +233,8 @@ export function WoTableInlineRowCells({
|
|||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
</InlineFieldCell>
|
||||
<InlineFieldCell>
|
||||
<VendorCell
|
||||
vendorId={draft.vendorId}
|
||||
company={draft.company}
|
||||
|
|
@ -231,8 +243,8 @@ export function WoTableInlineRowCells({
|
|||
vendors={vendors}
|
||||
onSave={(p) => onPatch(toVendorTablePatch(p))}
|
||||
/>
|
||||
</td>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
</InlineFieldCell>
|
||||
<InlineFieldCell>
|
||||
<ApptTimeCell
|
||||
apptTime={
|
||||
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
|
||||
|
|
@ -242,8 +254,8 @@ export function WoTableInlineRowCells({
|
|||
onPatch({ apptStart: start ?? "", apptEnd: end ?? "" });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
</InlineFieldCell>
|
||||
<InlineFieldCell>
|
||||
<StatusCell
|
||||
wo={draftRow as never}
|
||||
statusOptions={CREATION_INLINE_STATUSES}
|
||||
|
|
@ -251,7 +263,7 @@ export function WoTableInlineRowCells({
|
|||
onOpenSlideOver={() => {}}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>
|
||||
</td>
|
||||
</InlineFieldCell>
|
||||
|
||||
<InlineMutedDashCell />
|
||||
<InlineMutedDashCell />
|
||||
|
|
|
|||
|
|
@ -115,15 +115,17 @@ export function WoTableInlineRow({
|
|||
pointerEvents: saving ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "0 0 0 6px" }} />
|
||||
<td style={{ padding: "8px 0 0 6px", verticalAlign: "top" }} />
|
||||
|
||||
<td style={{ padding: "0 2px 0 8px" }}>
|
||||
<Flag
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden="true"
|
||||
style={{ color: "var(--color-border)" }}
|
||||
/>
|
||||
<td style={{ padding: "8px 2px 0 8px", verticalAlign: "top" }}>
|
||||
<div className="flex items-center" style={{ minHeight: 34 }}>
|
||||
<Flag
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden="true"
|
||||
style={{ color: "var(--color-border)" }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<WoTableInlineRowCells
|
||||
|
|
@ -145,7 +147,7 @@ export function WoTableInlineRow({
|
|||
onRequestComplete={() => setConfirmComplete(true)}
|
||||
/>
|
||||
|
||||
<td style={{ padding: "0 4px" }}>
|
||||
<td style={{ padding: "8px 4px 0", verticalAlign: "top" }}>
|
||||
<WoTableInlineRowActions
|
||||
canSave={canSave}
|
||||
saving={saving}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useId } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
|
@ -23,6 +24,8 @@ type PocContactFieldsProps = {
|
|||
onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
|
||||
helperText?: string;
|
||||
showPrimaryErrors?: boolean;
|
||||
/** Labels the primary name/phone inputs with a persistent required asterisk. */
|
||||
primaryRequired?: boolean;
|
||||
};
|
||||
|
||||
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
|
||||
|
|
@ -135,7 +138,9 @@ export function PocContactFields({
|
|||
onAdditionalContactsChange,
|
||||
helperText,
|
||||
showPrimaryErrors = false,
|
||||
primaryRequired = false,
|
||||
}: PocContactFieldsProps) {
|
||||
const fieldId = useId();
|
||||
const nameMissing = showPrimaryErrors && !pocName.trim();
|
||||
const phoneMissing = showPrimaryErrors && !pocPhone.trim();
|
||||
|
||||
|
|
@ -153,7 +158,13 @@ export function PocContactFields({
|
|||
)}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
{primaryRequired && (
|
||||
<FieldLabel required htmlFor={`${fieldId}-name`}>
|
||||
POC name
|
||||
</FieldLabel>
|
||||
)}
|
||||
<input
|
||||
id={`${fieldId}-name`}
|
||||
value={pocName}
|
||||
onChange={(event) => onPocNameChange(event.target.value)}
|
||||
placeholder="POC name"
|
||||
|
|
@ -168,7 +179,13 @@ export function PocContactFields({
|
|||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
{primaryRequired && (
|
||||
<FieldLabel required htmlFor={`${fieldId}-phone`}>
|
||||
POC phone
|
||||
</FieldLabel>
|
||||
)}
|
||||
<input
|
||||
id={`${fieldId}-phone`}
|
||||
value={pocPhone}
|
||||
onChange={(event) => onPocPhoneChange(event.target.value)}
|
||||
placeholder="POC phone"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function WizardStepLocationPocFields({
|
|||
onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)}
|
||||
helperText="Auto-filled from records — edit if outdated"
|
||||
showPrimaryErrors={showErrors && pocIncomplete}
|
||||
primaryRequired
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
|
||||
import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row";
|
||||
import {
|
||||
EMPTY_TABLE_DRAFT,
|
||||
type WorkOrderTableDraft,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
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/due-date-cell", () => ({
|
||||
DueDateCell: () => 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/appt-time-cell", () => ({
|
||||
ApptTimeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
|
||||
StatusCell: () => null,
|
||||
}));
|
||||
|
||||
function renderInlineRow(draft: WorkOrderTableDraft, onSave = vi.fn()) {
|
||||
const ui = (d: WorkOrderTableDraft) => (
|
||||
<table>
|
||||
<tbody>
|
||||
<WoTableInlineRow
|
||||
draft={d}
|
||||
draftDayKey="2026-06-01"
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
allRows={[]}
|
||||
onChange={vi.fn()}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const view = renderWithProviders(ui(draft), { withAuth: false });
|
||||
return { ...view, rerenderDraft: (d: WorkOrderTableDraft) => view.rerender(ui(d)) };
|
||||
}
|
||||
|
||||
const COMPLETE_DRAFT: WorkOrderTableDraft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: 9,
|
||||
woNumber: "12345678901",
|
||||
pocName: "Dana",
|
||||
pocPhone: "555-0100",
|
||||
};
|
||||
|
||||
describe("WoTableInlineRow required fields", () => {
|
||||
it("marks Site and WO # as required without showing errors before a Save attempt", () => {
|
||||
renderInlineRow(EMPTY_TABLE_DRAFT);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Site \*$/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Insert WO #")).toHaveTextContent("Insert WO # *");
|
||||
expect(screen.queryByText("Site is required")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals field errors only after Save is attempted, then clears them once corrected", () => {
|
||||
const onSave = vi.fn();
|
||||
const { rerenderDraft } = renderInlineRow(EMPTY_TABLE_DRAFT, onSave);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save work order" }));
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Site is required")).toBeInTheDocument();
|
||||
expect(screen.getByText("Work order number is required")).toBeInTheDocument();
|
||||
|
||||
rerenderDraft(COMPLETE_DRAFT);
|
||||
|
||||
expect(screen.queryByText("Site is required")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save work order" }));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PocContactFields required primary contact", () => {
|
||||
it("labels POC name and phone with a required asterisk when the contact is required", () => {
|
||||
renderWithProviders(
|
||||
<PocContactFields
|
||||
pocName=""
|
||||
pocPhone=""
|
||||
pocNotes=""
|
||||
additionalContacts={[]}
|
||||
primaryRequired
|
||||
onPocNameChange={vi.fn()}
|
||||
onPocPhoneChange={vi.fn()}
|
||||
onPocNotesChange={vi.fn()}
|
||||
onAdditionalContactsChange={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/POC name/)).toHaveAttribute("placeholder", "POC name");
|
||||
expect(screen.getByLabelText(/POC phone/)).toHaveAttribute("placeholder", "POC phone");
|
||||
expect(screen.getByText("POC name").closest("label")).toHaveTextContent("POC name*");
|
||||
expect(screen.getByText("POC phone").closest("label")).toHaveTextContent("POC phone*");
|
||||
expect(screen.queryByText("Contact name is required")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue