Compare commits

...

17 commits

Author SHA1 Message Date
Arthur Bassi
8d26a07fa4
Merge pull request #156 from Sea-Haven-Industries/feature/sh-166-accessible-row-flags
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): expose accessible names on board row flags [SH-166]
2026-08-31 16:29:23 -03:00
Arthur Bassi
a3b889c15e
Merge branch 'dev' into feature/sh-166-accessible-row-flags 2026-08-31 16:04:27 -03:00
Arthur Bassi
88c3d08afa
Merge pull request #153 from Sea-Haven-Industries/feat/sh-131-appt-time
feat(work-orders): make appointment time optional in create flows [SH-131]
2026-08-31 15:51:12 -03:00
Arthur Bassi
a90acad331 test(work-orders): refresh New WO Linux visual baseline after merge
CI failed on 73 pixels in the board behind the wizard. Use the Linux actual so visual regression matches the merged Assigned To board.
2026-08-31 15:21:24 -03:00
Arthur Bassi
55a6ff03b1 merge(dev): keep Linux CI New WO visual baseline
The only conflict was wo-new.png (Type and schedule). Appt Time is on a later step, so origin/dev's Assigned To screenshot is the correct combined UI.
2026-08-31 15:11:43 -03:00
Arthur Bassi
065ebbd403 fix(work-orders): expose flag picker as named dialog 2026-08-31 11:46:04 -03:00
Arthur Bassi
07c03e99b9 test(work-orders): assert empty apptTime board PATCH and refetch
EOF
2026-08-31 10:54:28 -03:00
Arthur Bassi
8d44b1868c fix(work-orders): expose disabled flag name via img role 2026-08-31 09:49:09 -03:00
Arthur Bassi
bae6d15043
Merge branch 'dev' into feature/sh-166-accessible-row-flags 2026-08-31 09:43:03 -03:00
Arthur Bassi
8b745608dc Merge branch 'feat/sh-131-appt-time' of https://github.com/Sea-Haven-Industries/shoc-frontend-new into feat/sh-131-appt-time 2026-08-31 09:36:35 -03:00
Arthur Bassi
ac9f4ff6e7
Merge branch 'dev' into feat/sh-131-appt-time 2026-08-31 09:36:18 -03:00
Arthur Bassi
15459e8d40 fix(work-orders): let picker toggle clear appointment start and end
EOF
2026-08-31 09:35:49 -03:00
Arthur Bassi
b619b8ae13 feat(work-orders): expose accessible names on board row flags
Give keyboard and screen-reader users color-independent labels
when setting, changing, or clearing a work-order flag.
2026-08-28 15:06:59 -03:00
Arthur Bassi
740048508c
Merge branch 'dev' into feat/sh-131-appt-time 2026-08-28 12:31:03 -03:00
Arthur Bassi
650543abb3 test(e2e): refresh wo-mobile-navigation visual baseline 2026-08-28 11:55:29 -03:00
Arthur Bassi
fb54414ade fix(work-orders): parse start-only appointment times and refresh visual baselines 2026-08-28 11:46:54 -03:00
Arthur Bassi
8bb126b1a3 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.
2026-08-28 11:05:43 -03:00
30 changed files with 606 additions and 126 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

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

@ -41,6 +41,7 @@ function indicatorCount(draft: WorkOrderAdvancedFilters): number {
function FlagColorDot({ value }: { value: string }) {
return (
<span
aria-hidden="true"
className="size-3 shrink-0 rounded-full"
style={{ background: value, border: "1px solid var(--color-border)" }}
/>

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,62 @@ 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 nextStartSelection(
currentStart: string,
currentEnd: string,
slot: string,
): { start: string; end: string } {
if (slot === currentStart) {
return { start: "", end: "" };
}
return {
start: slot,
end: currentEnd && currentEnd <= slot ? "" : currentEnd,
};
}
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 +88,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 +108,56 @@ 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) => {
const next = nextStartSelection(start, end, t);
setStart(next.start);
setEnd(next.end);
}}
/>
</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={(t) => {
setEnd(t === end ? "" : t);
}}
/>
</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

@ -1,7 +1,7 @@
import { useState } from "react";
import { Flag, X } from "lucide-react";
import { Popover } from "@mui/material";
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
import { FLAG_COLORS, flagTriggerAccessibleName } from "@/domain/work-orders/flag-colors";
type FlagCellProps = {
color?: string | null;
@ -11,11 +11,17 @@ type FlagCellProps = {
export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const triggerName = flagTriggerAccessibleName(color);
const popoverOpen = Boolean(anchorEl);
if (disabled) {
return (
<span className="flex h-[22px] w-[22px] items-center justify-center opacity-30">
<Flag size={14} />
<span
role="img"
className="flex h-[22px] w-[22px] items-center justify-center opacity-30"
aria-label={triggerName}
>
<Flag size={14} aria-hidden="true" />
</span>
);
}
@ -24,15 +30,19 @@ export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
<>
<button
type="button"
aria-label={triggerName}
aria-haspopup="dialog"
aria-expanded={popoverOpen}
title={triggerName}
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
title={color ? "Flagged — click to change" : "Add color flag"}
className="flex h-[22px] w-[22px] items-center justify-center transition-opacity hover:opacity-80"
className="flex h-[22px] w-[22px] items-center justify-center transition-opacity hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
>
<Flag
size={14}
aria-hidden="true"
strokeWidth={color ? 1.5 : 1.75}
style={{
color: color ?? "var(--color-text-muted)",
@ -41,37 +51,48 @@ export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
/>
</button>
<Popover
open={Boolean(anchorEl)}
open={popoverOpen}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }}
slotProps={{
paper: {
className: "rounded-lg border border-border p-2",
role: "dialog",
"aria-label": "Choose flag color",
},
}}
>
<div className="grid grid-cols-4 gap-1.5">
{FLAG_COLORS.map((c) => (
<button
key={c.value}
type="button"
title={c.name}
onClick={() => {
onChange(c.value);
setAnchorEl(null);
}}
className="flex h-7 w-7 items-center justify-center rounded transition-transform hover:scale-110"
>
<span
className="rounded-full"
style={{
width: 18,
height: 18,
background: c.value,
boxShadow:
color === c.value ? `0 0 0 2px var(--card), 0 0 0 4px ${c.value}` : "none",
{FLAG_COLORS.map((c) => {
const selected = color === c.value;
return (
<button
key={c.value}
type="button"
aria-label={c.name}
aria-pressed={selected}
title={c.name}
onClick={() => {
onChange(c.value);
setAnchorEl(null);
}}
/>
</button>
))}
className="flex h-7 w-7 items-center justify-center rounded transition-transform hover:scale-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
>
<span
aria-hidden="true"
className="rounded-full"
style={{
width: 18,
height: 18,
background: c.value,
boxShadow: selected ? `0 0 0 2px var(--card), 0 0 0 4px ${c.value}` : "none",
}}
/>
</button>
);
})}
</div>
<button
type="button"
@ -81,7 +102,7 @@ export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
}}
className="mt-2 flex w-full items-center gap-1.5 border-t border-border px-1 pt-2 text-xs text-[var(--color-text-secondary)] transition-colors hover:opacity-80"
>
<X size={12} /> Clear flag
<X size={12} aria-hidden="true" /> Clear flag
</button>
</Popover>
</>

View file

@ -1,6 +1,6 @@
export const WO_TABLE_COLS = [
{ key: "grip", label: "", w: 28 },
{ key: "flag", label: "", w: 28 },
{ key: "flag", label: "", srLabel: "Flag", w: 28 },
{ key: "site", label: "SITE", w: 168 },
{ key: "wo", label: "WO", w: 120 },
{ key: "type", label: "TYPE OF WO", w: 108 },

View file

@ -4,27 +4,32 @@ export function WoTableHead() {
return (
<thead className="sticky top-0 z-10">
<tr>
{WO_TABLE_COLS.map((col) => (
<th
key={col.key}
style={{
width: col.w,
minWidth: col.w,
padding: "11px 14px",
textAlign: "left",
fontSize: 10.5,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--foreground)",
whiteSpace: "nowrap",
background: "var(--color-bg-muted)",
borderBottom: "1px solid var(--border)",
}}
>
{col.label}
</th>
))}
{WO_TABLE_COLS.map((col) => {
const srLabel = "srLabel" in col ? col.srLabel : undefined;
return (
<th
key={col.key}
scope="col"
style={{
width: col.w,
minWidth: col.w,
padding: "11px 14px",
textAlign: "left",
fontSize: 10.5,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--foreground)",
whiteSpace: "nowrap",
background: "var(--color-bg-muted)",
borderBottom: "1px solid var(--border)",
}}
>
{col.label}
{Boolean(srLabel) && <span className="sr-only">{srLabel}</span>}
</th>
);
})}
</tr>
</thead>
);

View file

@ -118,7 +118,12 @@ export function WoTableInlineRow({
<td style={{ padding: "0 0 0 6px" }} />
<td style={{ padding: "0 2px 0 8px" }}>
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
<Flag
size={14}
strokeWidth={1.75}
aria-hidden="true"
style={{ color: "var(--color-border)" }}
/>
</td>
<WoTableInlineRowCells

View file

@ -11,3 +11,23 @@ export const FLAG_COLORS: { name: string; value: string }[] = [
];
export const FLAG_COLOR_VALUES = FLAG_COLORS.map((c) => c.value);
export function flagColorName(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.toLowerCase();
return FLAG_COLORS.find((color) => color.value.toLowerCase() === normalized)?.name ?? null;
}
/** Accessible trigger name — never color-only. */
export function flagTriggerAccessibleName(color: string | null | undefined): string {
const name = flagColorName(color);
if (name) {
return `${name} flag`;
}
if (color) {
return "Flagged";
}
return "Add flag";
}

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

@ -151,6 +151,13 @@ export function parseApptTime(appt: string): { start: string; end: string } {
}
return { start: `${pad2(h)}:${single[2]}`, end: "" };
}
const startOnly = appt.trim().match(/^(\d{1,2}):(\d{2})$/);
if (startOnly) {
return {
start: `${startOnly[1].padStart(2, "0")}:${startOnly[2]}`,
end: "",
};
}
return { start: "", end: "" };
}

View file

@ -0,0 +1,140 @@
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, formatApptRange } 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("clears a stored start-only time when Start is empty", () => {
const onSave = vi.fn();
commitApptTimeSelection("", "", "09:00", onSave);
expect(onSave).toHaveBeenCalledWith("");
});
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();
});
it("clears optional end when the selected end slot is toggled off", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="09:00 – 11:00" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: formatApptRange("09:00", "11:00") }));
const endSlots = await screen.findAllByRole("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");
});
it("clears start and end when the selected start slot is toggled off", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="09:00 – 11:00" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: formatApptRange("09:00", "11:00") }));
const startSlots = await screen.findAllByRole("button", { name: fmt12("09:00") });
await user.click(startSlots[0]!);
await user.click(screen.getByRole("button", { name: /^done$/i }));
expect(onSave).toHaveBeenCalledWith("");
});
it("hydrates start-only stored times so Done can clear them", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ApptTimeCell apptTime="09:00" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: fmt12("09:00") }));
expect(await screen.findByText("Pick end (optional)")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^done$/i }));
expect(onSave).toHaveBeenCalledWith("09:00");
});
});

View file

@ -0,0 +1,59 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FlagCell } from "@/app/(protected)/workorders/_components/list/table/cells/flag-cell";
import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
function flagPickerDialog() {
return screen.getByRole("dialog", { name: "Choose flag color" });
}
describe("FlagCell", () => {
it("sets, changes, and clears a flag by named controls", () => {
const onChange = vi.fn();
const { rerender } = render(<FlagCell color={null} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "Add flag" }));
fireEvent.click(within(flagPickerDialog()).getByRole("button", { name: "Red" }));
expect(onChange).toHaveBeenCalledWith("#EF4444");
expect(screen.queryByRole("dialog", { name: "Choose flag color" })).toBeNull();
rerender(<FlagCell color="#EF4444" onChange={onChange} />);
expect(screen.getByRole("button", { name: "Red flag" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Red flag" }));
fireEvent.click(within(flagPickerDialog()).getByRole("button", { name: "Blue" }));
expect(onChange).toHaveBeenCalledWith("#3B82F6");
rerender(<FlagCell color="#3B82F6" onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "Blue flag" }));
fireEvent.click(within(flagPickerDialog()).getByRole("button", { name: "Clear flag" }));
expect(onChange).toHaveBeenCalledWith(null);
expect(screen.queryByRole("dialog", { name: "Choose flag color" })).toBeNull();
});
it("exposes a non-color name when the hex is not in the palette", () => {
render(<FlagCell color="#000000" onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: "Flagged" })).toBeInTheDocument();
});
it("names a disabled flag without exposing a button", () => {
const onChange = vi.fn();
render(<FlagCell color="#EF4444" disabled onChange={onChange} />);
expect(screen.getByRole("img", { name: "Red flag" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Red flag" })).toBeNull();
expect(onChange).not.toHaveBeenCalled();
});
});
describe("WoTableHead flag column", () => {
it("exposes a screen-reader label for the flag column", () => {
render(
<table>
<WoTableHead />
</table>,
);
expect(screen.getByRole("columnheader", { name: "Flag" })).toBeInTheDocument();
});
});

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

@ -98,8 +98,8 @@ describe("WoTableRow completed selective lock", () => {
{ withAuth: false },
);
expect(screen.getByTitle("Add color flag")).toBeEnabled();
fireEvent.click(screen.getByTitle("Add color flag"));
expect(screen.getByRole("button", { name: "Add flag" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Add flag" }));
expect(screen.getByText("Clear flag")).toBeInTheDocument();
expect(screen.queryByTitle("Edit")).not.toBeInTheDocument();
expect(screen.getByTitle("View details")).toBeInTheDocument();

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { flagColorName, flagTriggerAccessibleName } from "@/domain/work-orders/flag-colors";
describe("flag color accessible names", () => {
it("resolves palette hex values to names", () => {
expect(flagColorName("#EF4444")).toBe("Red");
expect(flagColorName("#ef4444")).toBe("Red");
expect(flagColorName("#3B82F6")).toBe("Blue");
});
it("returns null for empty or unknown values", () => {
expect(flagColorName(null)).toBeNull();
expect(flagColorName(undefined)).toBeNull();
expect(flagColorName("")).toBeNull();
expect(flagColorName("#000000")).toBeNull();
});
it("builds trigger names that are not color-only", () => {
expect(flagTriggerAccessibleName(null)).toBe("Add flag");
expect(flagTriggerAccessibleName("#EF4444")).toBe("Red flag");
expect(flagTriggerAccessibleName("#000000")).toBe("Flagged");
});
});

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

@ -70,6 +70,9 @@ describe("expandTablePatchToBoardFieldOps", () => {
expect(expandTablePatchToBoardFieldOps({ severity: null })).toEqual([
{ field: "severity", value: "" },
]);
expect(expandTablePatchToBoardFieldOps({ apptTime: "" })).toEqual([
{ field: "apptTime", value: "" },
]);
});
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {

View file

@ -537,3 +537,63 @@ describe("useWorkOrderTableMutations patchField success callbacks", () => {
await waitFor(() => expect(onSuccessFirst).toHaveBeenCalledTimes(1));
});
});
describe("useWorkOrderTableMutations apptTime clear", () => {
it("patches empty apptTime and keeps the appointment date on refetch", async () => {
const datedRow: WorkOrderTableRow = {
...BASE_ROW,
scheduledOn: "2026-06-25",
apptStart: "09:00",
apptEnd: "11:00",
apptTime: "09:00 – 11:00",
vendorId: "7",
primaryDispatchId: 10,
dispatchRowVersion: "disp-v1",
};
const clearedRow: WorkOrderTableRow = {
...datedRow,
apptStart: "",
apptEnd: "",
apptTime: "",
rowVersion: "wo-v2",
};
patchBoardField.mockReset();
patchBoardField.mockResolvedValue(clearedRow);
const onPatch = vi.fn();
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch,
clearPatch: () => undefined,
getRow: () => datedRow,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(42, { apptTime: "" });
});
await waitFor(() =>
expect(onPatch).toHaveBeenCalledWith(
42,
expect.objectContaining({
apptTime: "",
scheduledOn: "2026-06-25",
}),
),
);
expect(patchBoardField).toHaveBeenCalledWith(
42,
expect.objectContaining({
field: "apptTime",
value: "",
workOrderVersion: "AAAAAAA=",
dispatchVersion: "disp-v1",
primaryDispatchId: 10,
}),
);
});
});

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

@ -3,10 +3,21 @@ import {
fmtDateRange,
fmtShortDate,
pad2,
parseApptTime,
rangeDayCount,
todayIso,
} from "@/domain/work-orders/utils/wizard-date-utils";
describe("parseApptTime", () => {
it("parses a 24-hour start-only value", () => {
expect(parseApptTime("09:00")).toEqual({ start: "09:00", end: "" });
});
it("leaves free-text labels unparsed", () => {
expect(parseApptTime("AM before 2pm")).toEqual({ start: "", end: "" });
});
});
describe("fmtShortDate", () => {
it("formats ISO dates as Mon D", () => {
expect(fmtShortDate("2026-06-08")).toBe("Jun 8");

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([]);