mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
Merge branch 'dev' into feat/sh-101-uplift-lifecycle
This commit is contained in:
commit
b177987081
16 changed files with 554 additions and 21 deletions
|
|
@ -0,0 +1,56 @@
|
|||
import type { MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
|
||||
type DueDateCalendarDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
value: string;
|
||||
onSave: (iso: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Emergency Due Date picker — dedicated calendar Dialog (no time, no week/range).
|
||||
* Reuses the shared MiniCalendar used by the standard Due Date popover.
|
||||
*/
|
||||
export function DueDateCalendarDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
onSave,
|
||||
}: DueDateCalendarDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 320 },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-5" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogTitle
|
||||
className="mb-3 p-0"
|
||||
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
Due Date
|
||||
</DialogTitle>
|
||||
<div className="flex justify-center">
|
||||
<MiniCalendar
|
||||
value={value}
|
||||
onChange={(iso) => {
|
||||
onSave(iso);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from "react";
|
||||
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
|
||||
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
|
||||
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
type DueDateCellProps = {
|
||||
type: WOType | "";
|
||||
value: string;
|
||||
q?: string;
|
||||
highlight?: boolean;
|
||||
onSave: (date: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Board / slide-over Due Date editor.
|
||||
* Emergency → dedicated calendar Dialog; other types → standard DateCell popover.
|
||||
*/
|
||||
export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (type !== "Emergency") {
|
||||
return <DateCell value={value} q={q} highlight={highlight} onSave={onSave} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
|
||||
style={
|
||||
highlight
|
||||
? {
|
||||
boxShadow: "0 0 0 2px var(--color-accent)",
|
||||
background: "var(--color-primary-tint)",
|
||||
padding: "2px 8px",
|
||||
margin: "0 -8px",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<CalendarIcon
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
/>
|
||||
</button>
|
||||
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onSave} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ export function StatusCell({
|
|||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its service window. Reschedule to unlock status updates."
|
||||
title="This WO missed its deadline. Update the Due Date to unlock status updates."
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
|
|
@ -83,8 +83,7 @@ export function StatusCell({
|
|||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. A new date must be scheduled before the status can be
|
||||
updated.
|
||||
This work order is past due. Update the Due Date before the status can be updated.
|
||||
</Text>
|
||||
|
||||
<button
|
||||
|
|
@ -98,7 +97,7 @@ export function StatusCell({
|
|||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Reschedule →
|
||||
Update Due Date →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
|
|||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import { SiteCell } from "./cells/site-cell";
|
||||
|
|
@ -106,7 +107,11 @@ export function WoTableInlineRowCells({
|
|||
</td>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
|
||||
<DueDateCell
|
||||
type={draft.type}
|
||||
value={draft.dueDate}
|
||||
onSave={(date) => onPatch({ dueDate: date })}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
import { SiteCell } from "./cells/site-cell";
|
||||
import { TypeCell } from "./cells/type-cell";
|
||||
|
|
@ -16,6 +17,7 @@ type WoTableRowCellsProps = {
|
|||
sitePlace?: { city: string; state: string };
|
||||
search?: string;
|
||||
forceScheduleHighlight?: boolean;
|
||||
forceDueDateHighlight?: boolean;
|
||||
mutationCellStyle?: CSSProperties;
|
||||
handlers: WoTableRowHandlers;
|
||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
|
|
@ -29,6 +31,7 @@ export function WoTableRowCells({
|
|||
sitePlace,
|
||||
search,
|
||||
forceScheduleHighlight,
|
||||
forceDueDateHighlight,
|
||||
mutationCellStyle,
|
||||
handlers,
|
||||
onPatchRow,
|
||||
|
|
@ -104,7 +107,7 @@ export function WoTableRowCells({
|
|||
<DateCell
|
||||
value={row.scheduledOn}
|
||||
q={search}
|
||||
highlight={forceScheduleHighlight || row.isPastDue}
|
||||
highlight={forceScheduleHighlight}
|
||||
allowWeekMode
|
||||
targetWeek={row.targetWeek}
|
||||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||
|
|
@ -116,7 +119,13 @@ export function WoTableRowCells({
|
|||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DateCell value={row.dueDate} q={search} onSave={(date) => onPatchRow({ dueDate: date })} />
|
||||
<DueDateCell
|
||||
type={row.type}
|
||||
value={row.dueDate}
|
||||
q={search}
|
||||
highlight={forceDueDateHighlight || row.isPastDue}
|
||||
onSave={(date) => onPatchRow({ dueDate: date })}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ type WoTableRowProps = {
|
|||
pmTypes: string[];
|
||||
search?: string;
|
||||
forceScheduleHighlight?: boolean;
|
||||
forceDueDateHighlight?: boolean;
|
||||
isSelected: boolean;
|
||||
isFlashing: boolean;
|
||||
dragViz: DragVizState;
|
||||
|
|
@ -61,6 +62,7 @@ export function WoTableRow({
|
|||
pmTypes,
|
||||
search,
|
||||
forceScheduleHighlight,
|
||||
forceDueDateHighlight,
|
||||
isSelected,
|
||||
isFlashing,
|
||||
dragViz,
|
||||
|
|
@ -154,6 +156,7 @@ export function WoTableRow({
|
|||
sitePlace={sitePlace}
|
||||
search={search}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={forceDueDateHighlight}
|
||||
mutationCellStyle={mutationCellStyle}
|
||||
handlers={handlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function WoTable({
|
|||
onOpenAdvancedFilters,
|
||||
onPatch,
|
||||
}: WoTableProps) {
|
||||
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
||||
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
|
||||
const { data: locationSites = [] } = useLocationSites();
|
||||
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||
|
|
@ -69,7 +69,7 @@ export function WoTable({
|
|||
onOpenCompDoc,
|
||||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight: setScheduleHighlightId,
|
||||
onScheduleHighlight: setDueDateHighlightId,
|
||||
});
|
||||
|
||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||
|
|
@ -83,7 +83,8 @@ export function WoTable({
|
|||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
search={tableData.search}
|
||||
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={dueDateHighlightId === row.id}
|
||||
isSelected={tableData.selectedId === row.id}
|
||||
isFlashing={tableData.flashId === row.id}
|
||||
dragViz={tableData.dragViz}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { useState } from "react";
|
||||
import { CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
type WizardDueDateFieldProps = {
|
||||
type: WOType | "";
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (iso: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wizard Due Date editor.
|
||||
* Emergency → dedicated calendar Dialog; other types → WizardDateField popover.
|
||||
*/
|
||||
export function WizardDueDateField({
|
||||
type,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: WizardDueDateFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (type !== "Emergency") {
|
||||
return <WizardDateField value={value} placeholder={placeholder} onChange={onChange} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-center gap-2 transition-colors"
|
||||
style={{
|
||||
height: 36,
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
background: "var(--color-bg-surface)",
|
||||
padding: "0 10px",
|
||||
outline: "none",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.boxShadow = "0 0 0 2px var(--color-primary)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<CalendarIcon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
<span
|
||||
className="flex-1 truncate text-left"
|
||||
style={{
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 13,
|
||||
color: value ? "var(--foreground)" : "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{value ? fmtDate(value) : placeholder}
|
||||
</span>
|
||||
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
</button>
|
||||
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { Text } from "@/components/ui/text";
|
|||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
|
|
@ -197,7 +198,8 @@ export function WizardStepTypeSchedule({
|
|||
>
|
||||
Due Date
|
||||
</WizLabelRow>
|
||||
<WizardDateField
|
||||
<WizardDueDateField
|
||||
type={draft.type}
|
||||
value={draft.dueDate}
|
||||
placeholder="No due date"
|
||||
onChange={(iso) => onUpdate("dueDate", iso)}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ function tryPastDueRule({ fieldKey, actionKey }: AuditActionRuleContext): string
|
|||
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
|
||||
if (!matches) return null;
|
||||
|
||||
return "WO marked as Past Due. Scheduled date has passed without completion.";
|
||||
return "WO marked as Past Due. Due date has passed without completion.";
|
||||
}
|
||||
|
||||
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {
|
||||
|
|
|
|||
|
|
@ -12,22 +12,19 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
|||
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
|
||||
|
||||
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
|
||||
if (TERMINAL_STATUSES.has(row.status)) {
|
||||
if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
|
||||
return false;
|
||||
}
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const date = row.scheduledOn || row.dueDate;
|
||||
if (!date) {
|
||||
return false;
|
||||
}
|
||||
return date < today;
|
||||
return row.dueDate < today;
|
||||
}
|
||||
|
||||
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
|
||||
return {
|
||||
...row,
|
||||
// Prefer API board flag when true; otherwise recompute from scheduledOn/dueDate.
|
||||
isPastDue: row.isPastDue || computeIsPastDue(row),
|
||||
// Recompute from dueDate (same date-only rule as BE). Avoid sticky
|
||||
// `apiTrue || compute` so a future Due Date clears Past Due immediately.
|
||||
isPastDue: computeIsPastDue(row),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { ThemeProvider } from "@mui/material";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
|
||||
import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
import { muiTheme } from "@/lib/theme/mui-theme";
|
||||
|
||||
function renderWithTheme(ui: React.ReactElement) {
|
||||
return render(<ThemeProvider theme={muiTheme}>{ui}</ThemeProvider>);
|
||||
}
|
||||
|
||||
describe("DueDateCalendarDialog", () => {
|
||||
it("saves the selected day as ISO and closes", () => {
|
||||
const onSave = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
renderWithTheme(
|
||||
<DueDateCalendarDialog open onOpenChange={onOpenChange} value="2026-07-15" onSave={onSave} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Due Date")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "20" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("2026-07-20");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DueDateCell", () => {
|
||||
it("opens a dialog for Emergency and keeps popover for PM", () => {
|
||||
const onSave = vi.fn();
|
||||
|
||||
const { rerender } = renderWithTheme(
|
||||
<DueDateCell type="Emergency" value="2026-07-15" onSave={onSave} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /07-15|7\/15|15/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ThemeProvider theme={muiTheme}>
|
||||
<DueDateCell type="PM" value="2026-07-15" onSave={onSave} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WizardDueDateField", () => {
|
||||
it("opens a dialog when type is Emergency", () => {
|
||||
renderWithTheme(
|
||||
<WizardDueDateField type="Emergency" value="" placeholder="No due date" onChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /No due date/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { renderWithProviders } from "@/test/test-utils";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
|
||||
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
|
||||
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const HIGHLIGHT_BOX_SHADOW = "0 0 0 2px var(--color-accent)";
|
||||
|
||||
const unscheduledRow: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "CHI-1042",
|
||||
type: "PM",
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Test Dispatcher",
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
location: "Chicago Loop",
|
||||
locationId: "mock-loc-1",
|
||||
pm: "HVAC — Quarterly PM",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "",
|
||||
company: "",
|
||||
tech: "",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Pending",
|
||||
docStatus: "No",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "2026-07-15",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
};
|
||||
|
||||
const handlers: WoTableRowHandlers = {
|
||||
onRowClick: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch: vi.fn(),
|
||||
onScheduleDate: vi.fn(),
|
||||
onScheduleWeek: vi.fn(),
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onReorder: vi.fn(),
|
||||
onDragStart: vi.fn(),
|
||||
onDragOver: vi.fn(),
|
||||
onDrop: vi.fn(),
|
||||
onDragEnd: vi.fn(),
|
||||
};
|
||||
|
||||
/** Mirrors WoTable.renderRow: body `true` → Schedule On only. */
|
||||
function renderUnscheduledHarness() {
|
||||
const tableData = {
|
||||
advActive: false,
|
||||
unscheduled: [unscheduledRow],
|
||||
unschedOpen: true,
|
||||
draftDayKey: null,
|
||||
displayGroups: [],
|
||||
todayKey: null,
|
||||
startDayDraft: vi.fn(),
|
||||
setUnschedOpen: vi.fn(),
|
||||
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
|
||||
|
||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||
<tr key={String(row.id)}>
|
||||
<WoTableRowCells
|
||||
row={row}
|
||||
allRows={[row]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={false}
|
||||
handlers={handlers}
|
||||
onPatchRow={vi.fn()}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return renderWithProviders(
|
||||
<table>
|
||||
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
}
|
||||
|
||||
describe("WoTableBody unscheduled Schedule On highlight (SH-165)", () => {
|
||||
it("force-highlights Schedule On for Unscheduled rows, not Due Date", () => {
|
||||
renderUnscheduledHarness();
|
||||
|
||||
const scheduleOn = screen.getByRole("button", { name: /^—$/ });
|
||||
const dueDate = screen.getByRole("button", { name: /07-15/ });
|
||||
|
||||
expect(scheduleOn).toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
|
||||
expect(dueDate).not.toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
|
||||
});
|
||||
});
|
||||
|
|
@ -124,6 +124,24 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
}),
|
||||
).toEqual([{ field: "siteCode", value: "BK5" }]);
|
||||
});
|
||||
|
||||
it("keeps dueDate and scheduledDate patches independent", () => {
|
||||
expect(expandTablePatchToBoardFieldOps({ dueDate: "2026-08-01" })).toEqual([
|
||||
{ field: "dueDate", value: "2026-08-01" },
|
||||
]);
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
scheduledOn: "2026-07-15",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toEqual([
|
||||
{ field: "scheduleWeekOnly", value: "false" },
|
||||
{ field: "scheduledDate", value: "2026-07-15" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ dueDate: "2026-08-01", isPastDue: false })).toEqual([
|
||||
{ field: "dueDate", value: "2026-08-01" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBoardPatchPayload", () => {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ describe("formatBoardAuditAction", () => {
|
|||
oldValue: "false",
|
||||
newValue: "true",
|
||||
}),
|
||||
).toBe("WO marked as Past Due. Scheduled date has passed without completion.");
|
||||
).toBe("WO marked as Past Due. Due date has passed without completion.");
|
||||
});
|
||||
|
||||
it("falls back to from → to for unknown fields", () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
|||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
buildDayGroups,
|
||||
computeIsPastDue,
|
||||
enrichTableRow,
|
||||
matchesAdvancedFilters,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
|
@ -10,6 +12,11 @@ import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
|||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
function shiftIso(days: number): string {
|
||||
const d = new Date(`${todayIso()}T12:00:00.000Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: "1",
|
||||
|
|
@ -123,6 +130,122 @@ describe("matchesAdvancedFilters", () => {
|
|||
};
|
||||
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches Overdue when isPastDue is true", () => {
|
||||
const weekMonday = mondayOf(todayIso());
|
||||
const pastDue = makeRow({
|
||||
scheduledOn: weekMonday,
|
||||
...deriveDayFields(weekMonday),
|
||||
isPastDue: true,
|
||||
});
|
||||
const filters = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
dateRange: "custom" as const,
|
||||
customFrom: "0000-01-01",
|
||||
customTo: "9999-12-31",
|
||||
types: new Set(["Overdue" as const]),
|
||||
};
|
||||
expect(matchesAdvancedFilters(pastDue, filters)).toBe(true);
|
||||
expect(
|
||||
matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeIsPastDue", () => {
|
||||
it("is true when dueDate is before today even if scheduledOn is future", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: shiftIso(-1),
|
||||
scheduledOn: shiftIso(5),
|
||||
status: "Scheduled",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when dueDate is future even if scheduledOn is past", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: shiftIso(5),
|
||||
scheduledOn: shiftIso(-1),
|
||||
status: "Incomplete",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when dueDate is empty", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: "",
|
||||
scheduledOn: shiftIso(-3),
|
||||
status: "Scheduled",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for terminal statuses even with past dueDate", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: shiftIso(-2),
|
||||
scheduledOn: shiftIso(-2),
|
||||
status: "Completed",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: shiftIso(-2),
|
||||
status: "Canceled",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when dueDate is today", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: todayIso(),
|
||||
scheduledOn: shiftIso(-1),
|
||||
status: "Scheduled",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enrichTableRow", () => {
|
||||
it("recomputes isPastDue from dueDate and ignores sticky API true", () => {
|
||||
const enriched = enrichTableRow(
|
||||
makeRow({
|
||||
dueDate: shiftIso(5),
|
||||
scheduledOn: shiftIso(-1),
|
||||
isPastDue: true,
|
||||
status: "Scheduled",
|
||||
}),
|
||||
);
|
||||
expect(enriched.isPastDue).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isPastDue true from past dueDate even when API flag is false", () => {
|
||||
const enriched = enrichTableRow(
|
||||
makeRow({
|
||||
dueDate: shiftIso(-1),
|
||||
scheduledOn: shiftIso(5),
|
||||
isPastDue: false,
|
||||
status: "Incomplete",
|
||||
}),
|
||||
);
|
||||
expect(enriched.isPastDue).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWorkOrderTableRow", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue