diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index dbb11c52..fe420159 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -121,7 +121,7 @@ export function SlideOverInfoTabEditView({ onSave={(dueDate) => onDraftChange({ dueDate })} /> - + onDraftChange({ apptTime })} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx index 1c9d160f..ff7fda7f 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx @@ -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 ( +
+ {slots.map((t) => ( + + ))} +
+ ); +} + export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) { const [anchorEl, setAnchorEl] = useState(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" > - {display && display !== "—" ? ( + {showPlaceholder ? ( + Select appt time … + ) : ( {display} - ) : ( - Select … )} 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" } }} >
- + Start -
- {TIME_SLOTS.map((t) => ( - - ))} -
+ { + setStart(t); + if (end && end <= t) { + setEnd(""); + } + }} + />
- + End (optional) -
- {TIME_SLOTS.filter((t) => !start || t > start).map((t) => ( - - ))} -
+
- {start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"} + {apptTimePopoverHint(start, end)} -
diff --git a/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts b/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts index 33f8303e..43179ba9 100644 --- a/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts @@ -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, diff --git a/src/domain/work-orders/utils/appt-time-selection.ts b/src/domain/work-orders/utils/appt-time-selection.ts new file mode 100644 index 00000000..3ed8c19b --- /dev/null +++ b/src/domain/work-orders/utils/appt-time-selection.ts @@ -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(""); + } +} diff --git a/src/test/app/(protected)/workorders/appt-time-cell.test.tsx b/src/test/app/(protected)/workorders/appt-time-cell.test.tsx new file mode 100644 index 00000000..396d72a9 --- /dev/null +++ b/src/test/app/(protected)/workorders/appt-time-cell.test.tsx @@ -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(); + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx index b1627674..9ebf754e 100644 --- a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx @@ -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 })); diff --git a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx index 12ad3346..e2d04458 100644 --- a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx @@ -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( + , + ); + + 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(); diff --git a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts index b1124749..a086e5f5 100644 --- a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts @@ -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, diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index 0f003070..26991511 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -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, diff --git a/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts index 3ddd8dca..c5336aa1 100644 --- a/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts +++ b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts @@ -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({ diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 9d97358d..674d5d4a 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -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([]);