feat(work-orders): make appointment time optional in create flows

Allow empty Appt Time in wizard, table, and slide-over without blocking
create or complete. Omit appointment fields without vendorId so create
does not hit DispatchRequired.
This commit is contained in:
Arthur Bassi 2026-08-28 11:05:43 -03:00
parent 40ce66fbb3
commit 8bb126b1a3
11 changed files with 273 additions and 71 deletions

View file

@ -121,7 +121,7 @@ export function SlideOverInfoTabEditView({
onSave={(dueDate) => onDraftChange({ dueDate })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<SlideOverInfoRow label="Appt Time (optional)">
<ApptTimeCell
apptTime={draft.apptTime}
onSave={(apptTime) => onDraftChange({ apptTime })}

View file

@ -3,10 +3,12 @@ import { Clock } from "lucide-react";
import { Button, Popover } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
calcDuration,
apptTimePopoverHint,
commitApptTimeSelection,
} from "@/domain/work-orders/utils/appt-time-selection";
import {
fmt12,
formatApptRange,
formatApptTimeValue,
parseApptTime,
TIME_SLOTS,
} from "@/domain/work-orders/utils/wizard-date-utils";
@ -17,6 +19,48 @@ type ApptTimeCellProps = {
onSave: (value: string) => void;
};
const SLOT_HEADER_SX = {
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase" as const,
letterSpacing: "0.05em",
color: "var(--color-text-muted)",
};
function slotStyle(active: boolean): React.CSSProperties {
return {
background: active ? "var(--color-primary-tint)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-primary)",
fontWeight: active ? 600 : 400,
};
}
function ApptTimeSlotList({
slots,
active,
onPick,
}: {
slots: string[];
active: string;
onPick: (slot: string) => void;
}) {
return (
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{slots.map((t) => (
<button
key={t}
type="button"
onClick={() => onPick(t)}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(active === t)}
>
{fmt12(t)}
</button>
))}
</div>
);
}
export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [start, setStart] = useState(() => parseApptTime(apptTime).start);
@ -30,16 +74,13 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
const parsed = parseApptTime(apptTime);
const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime;
const showPlaceholder = !display || display === "—";
const endSlots = TIME_SLOTS.filter((t) => !start || t > start);
const slotStyle = (active: boolean): React.CSSProperties => ({
background: active ? "var(--color-primary-tint)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-primary)",
fontWeight: active ? 600 : 400,
});
const close = () => setAnchorEl(null);
const done = () => {
onSave(formatApptTimeValue(start, end));
setAnchorEl(null);
commitApptTimeSelection(start, end, apptTime, onSave);
close();
};
return (
@ -53,91 +94,51 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<Clock size={11} className="shrink-0 text-[var(--color-text-muted)]" />
{display && display !== "—" ? (
{showPlaceholder ? (
<span className="text-[13px] text-[var(--color-text-muted)]">Select appt time …</span>
) : (
<span className="whitespace-nowrap text-[13px] text-[var(--color-text-secondary)]">
{display}
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }}
>
<div className="grid grid-cols-2 border-b border-border">
<div className="border-r border-border">
<Text
as="p"
className="px-3 pb-1 pt-2"
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--color-text-muted)",
}}
>
<Text as="p" className="px-3 pb-1 pt-2" style={SLOT_HEADER_SX}>
Start
</Text>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.map((t) => (
<button
key={t}
type="button"
onClick={() => {
setStart(t);
if (end && end <= t) {
setEnd("");
}
}}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(start === t)}
>
{fmt12(t)}
</button>
))}
</div>
<ApptTimeSlotList
slots={TIME_SLOTS}
active={start}
onPick={(t) => {
setStart(t);
if (end && end <= t) {
setEnd("");
}
}}
/>
</div>
<div>
<Text
as="p"
className="px-3 pb-1 pt-2"
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--color-text-muted)",
}}
>
<Text as="p" className="px-3 pb-1 pt-2" style={SLOT_HEADER_SX}>
End <span className="font-normal normal-case">(optional)</span>
</Text>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
<button
key={t}
type="button"
onClick={() => setEnd(t)}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(end === t)}
>
{fmt12(t)}
</button>
))}
</div>
<ApptTimeSlotList slots={endSlots} active={end} onPick={setEnd} />
</div>
</div>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs text-[var(--color-text-muted)]">
{start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"}
{apptTimePopoverHint(start, end)}
</span>
<Button size="small" variant="contained" onClick={done} disabled={!start}>
<Button size="small" variant="contained" onClick={done}>
Done
</Button>
</div>

View file

@ -2,7 +2,10 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/wor
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { formatApptTimeValue } from "@/domain/work-orders/utils/wizard-date-utils";
/** Mutates `payload` with `vendorId` and, when applicable, single-day appt date/time. */
/**
* Mutates `payload` with `vendorId` and, when applicable, single-day appt date/time.
* Board create rejects appointment fields without vendorId (`DispatchRequired`).
*/
export function assignWizardVendorApptFields(
payload: WorkOrderBoardCreatePayload,
draft: WorkOrderWizardDraft,

View file

@ -0,0 +1,33 @@
import {
calcDuration,
formatApptTimeValue,
parseApptTime,
} from "@/domain/work-orders/utils/wizard-date-utils";
export function apptTimePopoverHint(start: string, end: string): string {
if (start && end) {
const duration = calcDuration(start, end);
return duration ? `Duration: ${duration}` : "—";
}
if (start) {
return "Pick end (optional)";
}
return "Pick a start time";
}
export function commitApptTimeSelection(
start: string,
end: string,
apptTime: string,
onSave: (value: string) => void,
): void {
if (start) {
onSave(formatApptTimeValue(start, end));
return;
}
const parsed = parseApptTime(apptTime);
const isRawUnparsed = Boolean(apptTime.trim()) && !parsed.start;
if (!isRawUnparsed) {
onSave("");
}
}

View file

@ -0,0 +1,96 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
import {
apptTimePopoverHint,
commitApptTimeSelection,
} from "@/domain/work-orders/utils/appt-time-selection";
import { fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
describe("apptTimePopoverHint", () => {
it("asks for start when empty", () => {
expect(apptTimePopoverHint("", "")).toBe("Pick a start time");
});
it("marks end as optional after start", () => {
expect(apptTimePopoverHint("08:00", "")).toBe("Pick end (optional)");
});
it("prefixes duration when both ends are set", () => {
expect(apptTimePopoverHint("08:00", "10:00")).toBe("Duration: 2h");
});
});
describe("commitApptTimeSelection", () => {
it("saves an empty string when start is empty and the current value parses empty", () => {
const onSave = vi.fn();
commitApptTimeSelection("", "", "", onSave);
expect(onSave).toHaveBeenCalledWith("");
});
it("does not clear a raw unparsed appointment label", () => {
const onSave = vi.fn();
commitApptTimeSelection("", "", "AM before 2pm", onSave);
expect(onSave).not.toHaveBeenCalled();
});
it("saves start only when end is omitted", () => {
const onSave = vi.fn();
commitApptTimeSelection("09:00", "", "", onSave);
expect(onSave).toHaveBeenCalledWith("09:00");
});
it("saves an en-dash range when both slots are set", () => {
const onSave = vi.fn();
commitApptTimeSelection("09:00", "11:00", "", onSave);
expect(onSave).toHaveBeenCalledWith("09:00 – 11:00");
});
});
describe("ApptTimeCell", () => {
it("shows the AAP empty placeholder", () => {
render(<ApptTimeCell apptTime="" onSave={vi.fn()} />);
expect(screen.getByRole("button", { name: /select appt time/i })).toBeInTheDocument();
});
it("lets Done confirm an empty selection", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /select appt time/i }));
const done = await screen.findByRole("button", { name: /^done$/i });
expect(done).toBeEnabled();
expect(screen.getByText("Pick a start time")).toBeInTheDocument();
await user.click(done);
expect(onSave).toHaveBeenCalledWith("");
});
it("saves start and optional end", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /select appt time/i }));
const startSlots = await screen.findAllByRole("button", { name: fmt12("09:00") });
await user.click(startSlots[0]!);
const endSlots = screen.getAllByRole("button", { name: fmt12("11:00") });
await user.click(endSlots[endSlots.length - 1]!);
await user.click(screen.getByRole("button", { name: /^done$/i }));
expect(onSave).toHaveBeenCalledWith("09:00 – 11:00");
});
it("keeps an unparsed label when Done is pressed without a start slot", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="AM before 2pm" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /am before 2pm/i }));
await user.click(await screen.findByRole("button", { name: /^done$/i }));
expect(onSave).not.toHaveBeenCalled();
});
});

View file

@ -138,6 +138,7 @@ describe("SlideOverInfoTabEditView additional contacts", () => {
{ withAuth: false },
);
expect(screen.getByText("Appt Time (optional)")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /save site 1/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /save site 1/i }));

View file

@ -3,6 +3,22 @@ import { describe, expect, it, vi } from "vitest";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
describe("WizardStepVendorTime Appt Time", () => {
it("marks Appt Time as optional", () => {
render(
<WizardStepVendorTime
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
vendors={[]}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
expect(screen.getByText("Appt Time")).toBeInTheDocument();
expect(screen.getAllByText("optional").length).toBeGreaterThan(0);
});
});
describe("WizardStepVendorTime closability gate", () => {
it("disables Completed when closability requirements are missing", () => {
const onUpdate = vi.fn();

View file

@ -194,6 +194,22 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(true);
});
it("does not require Appt Time for wizard step 3 or create", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345678901",
locationId: "99",
siteCode: "BK5",
pocName: "Jordan",
pocPhone: "555-0100",
apptStart: "",
apptEnd: "",
};
expect(isWizardStepValid(3, draft)).toBe(true);
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
});
it("allows provisional SH-prefixed woNumber", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,

View file

@ -299,6 +299,22 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.scheduledDate).toBeUndefined();
});
it("omits apptTime when start and end are empty even with a vendor", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
vendorId: "5",
scheduledOn: "2026-06-25",
apptStart: "",
apptEnd: "",
});
expect(payload.vendorId).toBe(5);
expect(payload.apptDate).toBe("2026-06-25");
expect(payload.apptTime).toBeUndefined();
});
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,

View file

@ -2,11 +2,25 @@ import { describe, expect, it } from "vitest";
import {
draftHasScheduleDate,
getInlineCreateMissing,
INLINE_CREATE_REQ_LABELS,
inlineCreateSaveHint,
inlineCreateSiteError,
} from "@/domain/work-orders/utils/get-inline-create-missing";
describe("getInlineCreateMissing", () => {
it("does not require Appt Time for inline create", () => {
expect(
getInlineCreateMissing({
site: "BK5",
locationId: "12",
woNumber: "25001",
pocName: "Jane",
pocPhone: "555-0100",
}),
).toEqual([]);
expect(Object.keys(INLINE_CREATE_REQ_LABELS)).not.toContain("apptTime");
});
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
expect(
getInlineCreateMissing({

View file

@ -77,6 +77,12 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput())).toEqual([]);
});
it("does not require appointment time to complete", () => {
const gaps = getClosabilityGaps(makeInput());
expect(gaps).toEqual([]);
expect(gaps.join(" ")).not.toMatch(/appt/i);
});
it("allows company without technician", () => {
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
expect(gaps).toEqual([]);