Merge branch 'dev' into fix/sh-296-vendor-invalid-dispatch
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 185 KiB |
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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)" }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
33
src/domain/work-orders/utils/appt-time-selection.ts
Normal 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("");
|
||||
}
|
||||
}
|
||||
|
|
@ -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: "" };
|
||||
}
|
||||
|
||||
|
|
|
|||
140
src/test/app/(protected)/workorders/appt-time-cell.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
59
src/test/app/(protected)/workorders/flag-cell.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
23
src/test/domain/work-orders/flag-colors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -582,3 +582,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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||