mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 05:22:04 +00:00
chore(work-orders): merge origin/dev and keep both search date windows
Keep SH-121 all-weeks constants and the SH-165 unassigned Custom range.
This commit is contained in:
commit
9801d6a50e
29 changed files with 1904 additions and 183 deletions
|
|
@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-52 overflow-y-auto">
|
<div className="max-h-52 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSave("", "");
|
||||||
|
setAnchorEl(null);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded px-1.5 py-1 transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
<span className="text-[13px] text-[var(--color-text-muted)]">Unassigned</span>
|
||||||
|
{!dispatcherId && <Check size={10} className="shrink-0 text-[var(--color-primary)]" />}
|
||||||
|
</button>
|
||||||
{filtered.map((d) => (
|
{filtered.map((d) => (
|
||||||
<button
|
<button
|
||||||
key={String(d.id)}
|
key={String(d.id)}
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,126 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { AlertTriangle, Check } from "lucide-react";
|
import { AlertTriangle, Check } from "lucide-react";
|
||||||
|
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ALL_WIZARD_STATUSES,
|
ALL_WIZARD_STATUSES,
|
||||||
type WizardWOStatus,
|
type WizardWOStatus,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
formatClosabilityMissingTitle,
|
||||||
|
getClosabilityGaps,
|
||||||
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
import { HoverChevron } from "./hover-chevron";
|
import { HoverChevron } from "./hover-chevron";
|
||||||
|
|
||||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||||
|
|
||||||
type StatusCellProps = {
|
type StatusCellProps = {
|
||||||
wo: WorkOrderTableRow;
|
wo: WorkOrderTableRow;
|
||||||
|
|
||||||
onChangeStatus: (status: WizardWOStatus) => void;
|
onChangeStatus: (status: WizardWOStatus) => void;
|
||||||
|
|
||||||
onOpenSlideOver: () => void;
|
onOpenSlideOver: () => void;
|
||||||
|
|
||||||
onPastDueAttempt?: () => void;
|
onPastDueAttempt?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function rowClosabilityGaps(wo: WorkOrderTableRow) {
|
||||||
|
return getClosabilityGaps({
|
||||||
|
dispatcherId: wo.dispatcherId,
|
||||||
|
dispatcherName: wo.dispatcherName,
|
||||||
|
company: wo.company,
|
||||||
|
tech: wo.tech,
|
||||||
|
completedDate: wo.completedDate ?? "",
|
||||||
|
woNumber: wo.woNumber,
|
||||||
|
pm: wo.pm,
|
||||||
|
docStatus: wo.docStatus,
|
||||||
|
mediaCount: wo.mediaCount,
|
||||||
|
type: wo.type,
|
||||||
|
upliftSummary: wo.upliftSummary,
|
||||||
|
hasPendingUplift: wo.hasPendingUplift,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function PastDueStatusCell({
|
||||||
|
onOpenSlideOver,
|
||||||
|
onPastDueAttempt,
|
||||||
|
}: {
|
||||||
|
onOpenSlideOver: () => void;
|
||||||
|
onPastDueAttempt?: () => void;
|
||||||
|
}) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPastDueAttempt?.();
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
}}
|
||||||
|
title="This WO missed its deadline. Update Schedule On 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)" }}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
||||||
|
Past Due
|
||||||
|
<HoverChevron />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
slotProps={{
|
||||||
|
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2 p-3"
|
||||||
|
style={{ background: "var(--status-pending-bg)" }}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.375,
|
||||||
|
color: "var(--status-pending-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This work order is past due. Update Schedule On before the status can be updated.
|
||||||
|
</Text>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenSlideOver();
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
Update Schedule On →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusCell({
|
export function StatusCell({
|
||||||
wo,
|
wo,
|
||||||
|
|
||||||
onChangeStatus,
|
onChangeStatus,
|
||||||
|
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
|
|
||||||
onPastDueAttempt,
|
onPastDueAttempt,
|
||||||
}: StatusCellProps) {
|
}: StatusCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const closabilityGaps = rowClosabilityGaps(wo);
|
||||||
|
|
||||||
if (wo.isPastDue) {
|
if (wo.isPastDue) {
|
||||||
return (
|
return (
|
||||||
<>
|
<PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
onPastDueAttempt?.();
|
|
||||||
|
|
||||||
setAnchorEl(e.currentTarget);
|
|
||||||
}}
|
|
||||||
title="This WO missed its deadline. Update Schedule On 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)" }}
|
|
||||||
>
|
|
||||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
|
||||||
Past Due
|
|
||||||
<HoverChevron />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
open={Boolean(anchorEl)}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
onClose={() => setAnchorEl(null)}
|
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
slotProps={{
|
|
||||||
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-start gap-2 p-3"
|
|
||||||
style={{ background: "var(--status-pending-bg)" }}
|
|
||||||
>
|
|
||||||
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 1.375,
|
|
||||||
color: "var(--status-pending-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
This work order is past due. Update Schedule On before the status can be updated.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
onOpenSlideOver();
|
|
||||||
|
|
||||||
setAnchorEl(null);
|
|
||||||
}}
|
|
||||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
|
||||||
>
|
|
||||||
Update Schedule On →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +130,6 @@ export function StatusCell({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||||
|
|
@ -122,7 +139,6 @@ export function StatusCell({
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -134,24 +150,38 @@ export function StatusCell({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||||
>
|
>
|
||||||
{ALL_WIZARD_STATUSES.map((status) => (
|
{ALL_WIZARD_STATUSES.map((status) => {
|
||||||
<button
|
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||||
key={status}
|
const missingTitle = isCompletedBlocked
|
||||||
type="button"
|
? formatClosabilityMissingTitle(closabilityGaps)
|
||||||
onClick={() => {
|
: undefined;
|
||||||
onChangeStatus(status);
|
|
||||||
|
|
||||||
setAnchorEl(null);
|
return (
|
||||||
}}
|
<button
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
key={status}
|
||||||
>
|
type="button"
|
||||||
<WoTableStatusLabel status={status} />
|
disabled={isCompletedBlocked}
|
||||||
|
title={missingTitle}
|
||||||
{wo.status === status && (
|
onClick={() => {
|
||||||
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
if (isCompletedBlocked) {
|
||||||
)}
|
return;
|
||||||
</button>
|
}
|
||||||
))}
|
onChangeStatus(status);
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
isCompletedBlocked
|
||||||
|
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
|
||||||
|
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<WoTableStatusLabel status={status} />
|
||||||
|
{Boolean(wo.status === status) && (
|
||||||
|
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Popover>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { Fragment, type ReactNode } from "react";
|
import { Fragment, type ReactNode } from "react";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import { Text } from "@/components/ui/text";
|
||||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
import {
|
||||||
|
UNASSIGNED_DAY_KEY,
|
||||||
|
UNSCHEDULED_DAY_KEY,
|
||||||
|
type WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||||
import { WoTableSectionHeader } from "./wo-table-section-header";
|
import { WoTableSectionHeader } from "./wo-table-section-header";
|
||||||
|
|
||||||
type WoTableBodyProps = {
|
type WoTableBodyProps = {
|
||||||
|
|
@ -15,11 +20,65 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
||||||
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unassignedRows = tableData.unassigned ?? [];
|
||||||
const showUnscheduled =
|
const showUnscheduled =
|
||||||
tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
|
(tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||||
|
// SH-165: "still loading" and "zero unassigned" are distinct states. Without
|
||||||
|
// this split the pin announced "No unassigned work orders." while the queue
|
||||||
|
// was still paging board/search on first mount.
|
||||||
|
const showUnassignedLoading =
|
||||||
|
tableData.unassignedOpen &&
|
||||||
|
unassignedRows.length === 0 &&
|
||||||
|
!tableData.unassignedError &&
|
||||||
|
tableData.unassignedLoading;
|
||||||
|
const showUnassignedEmpty =
|
||||||
|
tableData.unassignedOpen &&
|
||||||
|
unassignedRows.length === 0 &&
|
||||||
|
!tableData.unassignedError &&
|
||||||
|
!tableData.unassignedLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<WoTableSectionHeader
|
||||||
|
variant="unassigned"
|
||||||
|
label="Unassigned"
|
||||||
|
count={unassignedRows.length}
|
||||||
|
collapsed={!tableData.unassignedOpen}
|
||||||
|
onToggleCollapse={() => tableData.setUnassignedOpen((v) => !v)}
|
||||||
|
/>
|
||||||
|
{tableData.unassignedOpen && (
|
||||||
|
<>
|
||||||
|
{renderInlineRow(UNASSIGNED_DAY_KEY)}
|
||||||
|
{Boolean(tableData.unassignedError) && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
|
||||||
|
<Text variant="error" tone="error">
|
||||||
|
{tableData.unassignedError?.message ?? "Unable to load unassigned work orders."}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{showUnassignedLoading && (
|
||||||
|
<tr aria-busy="true">
|
||||||
|
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
|
||||||
|
<Text variant="feedback" tone="muted">
|
||||||
|
Loading unassigned work orders…
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{showUnassignedEmpty && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
|
||||||
|
<Text variant="feedback" tone="muted">
|
||||||
|
No unassigned work orders.
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{unassignedRows.map((row) => renderRow(row))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{showUnscheduled && (
|
{showUnscheduled && (
|
||||||
<>
|
<>
|
||||||
<WoTableSectionHeader
|
<WoTableSectionHeader
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
|
||||||
|
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||||
|
|
||||||
|
type CollapsibleSectionStyle = {
|
||||||
|
background: string;
|
||||||
|
borderTop: string;
|
||||||
|
borderBottom: string;
|
||||||
|
accentColor: string;
|
||||||
|
boxShadow: string;
|
||||||
|
labelFontFamily: string;
|
||||||
|
labelLetterSpacing: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_STYLES: Record<"unassigned" | "unscheduled", CollapsibleSectionStyle> = {
|
||||||
|
unassigned: {
|
||||||
|
background: "rgba(99, 102, 241, 0.08)",
|
||||||
|
borderTop: "1px solid #C7D2FE",
|
||||||
|
borderBottom: "1px solid #C7D2FE",
|
||||||
|
accentColor: "var(--primary)",
|
||||||
|
boxShadow: "inset 3px 0 0 var(--primary)",
|
||||||
|
labelFontFamily: "var(--font-mono)",
|
||||||
|
labelLetterSpacing: "0.08em",
|
||||||
|
},
|
||||||
|
unscheduled: {
|
||||||
|
background: "rgba(217, 119, 6, 0.08)",
|
||||||
|
borderTop: "1px solid #FDE68A",
|
||||||
|
borderBottom: "1px solid #FDE68A",
|
||||||
|
accentColor: "var(--warning)",
|
||||||
|
boxShadow: "inset 3px 0 0 var(--warning)",
|
||||||
|
labelFontFamily: "var(--font-mono)",
|
||||||
|
labelLetterSpacing: "0.08em",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type WoTableCollapsibleSectionHeaderProps = {
|
||||||
|
tone: "unassigned" | "unscheduled";
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
onAddWo?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableCollapsibleSectionHeader({
|
||||||
|
tone,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
onAddWo,
|
||||||
|
}: WoTableCollapsibleSectionHeaderProps) {
|
||||||
|
const style = SECTION_STYLES[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr style={{ background: style.background }}>
|
||||||
|
<td
|
||||||
|
colSpan={WO_TABLE_COLS.length}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 14px",
|
||||||
|
boxShadow: style.boxShadow,
|
||||||
|
borderTop: style.borderTop,
|
||||||
|
borderBottom: style.borderBottom,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center gap-2.5"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-label={`${label}, ${count} work order${count !== 1 ? "s" : ""}${collapsed ? ", collapsed" : ", expanded"}`}
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={14} className="shrink-0" style={{ color: style.accentColor }} />
|
||||||
|
<span
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontFamily: style.labelFontFamily,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: style.labelLetterSpacing,
|
||||||
|
color: style.accentColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
{count} work order{count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex items-center" style={{ color: style.accentColor }}>
|
||||||
|
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{Boolean(onAddWo) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
||||||
|
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
||||||
|
onClick={onAddWo}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Add WO
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,10 @@ import type {
|
||||||
WorkOrderTableDraft,
|
WorkOrderTableDraft,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
import {
|
||||||
|
UNASSIGNED_DAY_KEY,
|
||||||
|
UNSCHEDULED_DAY_KEY,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import {
|
import {
|
||||||
draftHasScheduleDate,
|
draftHasScheduleDate,
|
||||||
getInlineCreateMissing,
|
getInlineCreateMissing,
|
||||||
|
|
@ -66,7 +69,7 @@ export function WoTableInlineRow({
|
||||||
onCancel,
|
onCancel,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
}: WoTableInlineRowProps) {
|
}: WoTableInlineRowProps) {
|
||||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY;
|
||||||
const missing = getInlineCreateMissing(draft);
|
const missing = getInlineCreateMissing(draft);
|
||||||
const canSave = !saving && missing.length === 0;
|
const canSave = !saving && missing.length === 0;
|
||||||
const [showErrors, setShowErrors] = useState(false);
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { AlertTriangle, ChevronDown, ChevronRight, Plus } from "lucide-react";
|
import { AlertTriangle, Plus, Users } from "lucide-react";
|
||||||
import { WO_TABLE_COLS } from "./wo-table-cols";
|
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||||
|
import { WoTableCollapsibleSectionHeader } from "./wo-table-collapsible-section-header";
|
||||||
|
|
||||||
type WoTableSectionHeaderProps = {
|
type WoTableSectionHeaderProps = {
|
||||||
variant: "unscheduled" | "day";
|
variant: "unassigned" | "unscheduled" | "day";
|
||||||
label: string;
|
label: string;
|
||||||
count: number;
|
count: number;
|
||||||
isToday?: boolean;
|
isToday?: boolean;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
onAddWo: () => void;
|
onAddWo?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WoTableSectionHeader({
|
export function WoTableSectionHeader({
|
||||||
|
|
@ -20,58 +21,30 @@ export function WoTableSectionHeader({
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onAddWo,
|
onAddWo,
|
||||||
}: WoTableSectionHeaderProps) {
|
}: WoTableSectionHeaderProps) {
|
||||||
const isUnscheduled = variant === "unscheduled";
|
if (variant === "unassigned") {
|
||||||
|
|
||||||
if (isUnscheduled) {
|
|
||||||
return (
|
return (
|
||||||
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
|
<WoTableCollapsibleSectionHeader
|
||||||
<td
|
tone="unassigned"
|
||||||
colSpan={WO_TABLE_COLS.length}
|
icon={Users}
|
||||||
onClick={onToggleCollapse}
|
label={label}
|
||||||
style={{
|
count={count}
|
||||||
height: 40,
|
collapsed={collapsed}
|
||||||
padding: "0 14px",
|
onToggleCollapse={() => onToggleCollapse?.()}
|
||||||
cursor: "pointer",
|
/>
|
||||||
boxShadow: "inset 3px 0 0 var(--warning)",
|
);
|
||||||
borderTop: "1px solid #FDE68A",
|
}
|
||||||
borderBottom: "1px solid #FDE68A",
|
|
||||||
}}
|
if (variant === "unscheduled") {
|
||||||
>
|
return (
|
||||||
<div className="flex items-center gap-2.5">
|
<WoTableCollapsibleSectionHeader
|
||||||
<AlertTriangle size={14} className="shrink-0" style={{ color: "var(--warning)" }} />
|
tone="unscheduled"
|
||||||
<span
|
icon={AlertTriangle}
|
||||||
className="uppercase"
|
label={label}
|
||||||
style={{
|
count={count}
|
||||||
fontFamily: "var(--font-mono)",
|
collapsed={collapsed}
|
||||||
fontSize: 12,
|
onToggleCollapse={() => onToggleCollapse?.()}
|
||||||
fontWeight: 700,
|
onAddWo={onAddWo}
|
||||||
letterSpacing: "0.08em",
|
/>
|
||||||
color: "var(--warning)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
|
||||||
{count} work order{count !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
|
||||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAddWo();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={13} />
|
|
||||||
Add WO
|
|
||||||
</button>
|
|
||||||
<span className="ml-auto flex items-center" style={{ color: "var(--warning)" }}>
|
|
||||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,18 +98,17 @@ export function WoTableSectionHeader({
|
||||||
Today
|
Today
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
{Boolean(onAddWo) && (
|
||||||
type="button"
|
<button
|
||||||
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
type="button"
|
||||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
||||||
onClick={(e) => {
|
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
||||||
e.stopPropagation();
|
onClick={onAddWo}
|
||||||
onAddWo();
|
>
|
||||||
}}
|
<Plus size={13} />
|
||||||
>
|
Add WO
|
||||||
<Plus size={13} />
|
</button>
|
||||||
Add WO
|
)}
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,29 @@
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||||
import {
|
import {
|
||||||
useWorkOrdersBoard,
|
useWorkOrdersBoard,
|
||||||
useWorkOrdersBoardSearch,
|
useWorkOrdersBoardSearch,
|
||||||
|
useWorkOrdersUnassignedQueue,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
import {
|
import {
|
||||||
useWorkOrderLocations,
|
useWorkOrderLocations,
|
||||||
useWorkOrderVendors,
|
useWorkOrderVendors,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
|
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { UseQueryResult } from "@tanstack/react-query";
|
import type { UseQueryResult } from "@tanstack/react-query";
|
||||||
|
|
||||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
|
const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = [];
|
||||||
|
|
||||||
export type WorkOrdersBoardSource = {
|
export type WorkOrdersBoardSource = {
|
||||||
advActive: boolean;
|
advActive: boolean;
|
||||||
data: WorkOrderBoardListResult | undefined;
|
data: WorkOrderBoardListResult | undefined;
|
||||||
|
unassignedQueue: WorkOrderTableRow[];
|
||||||
|
unassignedError: Error | null;
|
||||||
|
unassignedLoading: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
|
|
@ -67,13 +74,35 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
|
||||||
error: searchError,
|
error: searchError,
|
||||||
refetch: refetchSearch,
|
refetch: refetchSearch,
|
||||||
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
||||||
|
const {
|
||||||
|
data: queueData,
|
||||||
|
isLoading: queueLoading,
|
||||||
|
isFetching: queueFetching,
|
||||||
|
error: queueError,
|
||||||
|
refetch: refetchQueue,
|
||||||
|
} = useWorkOrdersUnassignedQueue(!advActive);
|
||||||
|
|
||||||
|
const refetchBoardWithQueue: UseQueryResult<WorkOrderBoardListResult, Error>["refetch"] =
|
||||||
|
useCallback(
|
||||||
|
async (options) => {
|
||||||
|
const [boardResult] = await Promise.all([refetchBoard(options), refetchQueue()]);
|
||||||
|
return boardResult;
|
||||||
|
},
|
||||||
|
[refetchBoard, refetchQueue],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
advActive,
|
advActive,
|
||||||
data: advActive ? searchData : boardData,
|
data: advActive ? searchData : boardData,
|
||||||
|
unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE),
|
||||||
|
unassignedError: advActive ? null : (queueError ?? null),
|
||||||
|
// SH-165: the pinned queue has its own first-load state. Folding it into
|
||||||
|
// isFetching only made "still paging board/search" indistinguishable from
|
||||||
|
// "zero unassigned", so the pin announced an empty queue while loading.
|
||||||
|
unassignedLoading: advActive ? false : queueLoading,
|
||||||
isLoading: advActive ? searchLoading : boardLoading,
|
isLoading: advActive ? searchLoading : boardLoading,
|
||||||
isFetching: advActive ? searchFetching : boardFetching,
|
isFetching: advActive ? searchFetching : boardFetching || queueFetching,
|
||||||
error: advActive ? searchError : boardError,
|
error: advActive ? searchError : boardError,
|
||||||
refetch: advActive ? refetchSearch : refetchBoard,
|
refetch: advActive ? refetchSearch : refetchBoardWithQueue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@ import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hoo
|
||||||
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import {
|
import {
|
||||||
buildDayGroups,
|
buildDayGroups,
|
||||||
|
composeBoardSections,
|
||||||
filterByBarAndSearch,
|
filterByBarAndSearch,
|
||||||
|
mergeWeeklyAndUnassignedQueue,
|
||||||
|
rowIdSet,
|
||||||
splitScheduledUnscheduled,
|
splitScheduledUnscheduled,
|
||||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
|
|
||||||
|
|
@ -14,12 +18,31 @@ export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-
|
||||||
|
|
||||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
|
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
|
||||||
|
|
||||||
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
const { advActive, data, isLoading, isFetching, error, refetch } =
|
const {
|
||||||
useWorkOrdersBoardSource(filters);
|
advActive,
|
||||||
|
data,
|
||||||
|
unassignedQueue,
|
||||||
|
unassignedError,
|
||||||
|
unassignedLoading,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useWorkOrdersBoardSource(filters);
|
||||||
|
|
||||||
|
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
|
||||||
|
const items = useMemo(
|
||||||
|
() => mergeWeeklyAndUnassignedQueue(weeklyItems, unassignedQueue),
|
||||||
|
[weeklyItems, unassignedQueue],
|
||||||
|
);
|
||||||
|
const weeklyIds = useMemo(() => rowIdSet(weeklyItems), [weeklyItems]);
|
||||||
|
const queueIds = useMemo(() => rowIdSet(unassignedQueue), [unassignedQueue]);
|
||||||
|
|
||||||
const local = useWorkOrdersTableLocalState({
|
const local = useWorkOrdersTableLocalState({
|
||||||
items: data?.items ?? [],
|
items,
|
||||||
weekMonday: filters.weekMonday,
|
weekMonday: filters.weekMonday,
|
||||||
advActive,
|
advActive,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
|
@ -38,10 +61,18 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
return displayRows;
|
return displayRows;
|
||||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||||
|
|
||||||
const { scheduled, unscheduled } = useMemo(
|
const { unassigned, scheduled, unscheduled } = useMemo(() => {
|
||||||
() => (advActive ? splitScheduledUnscheduled(advResults) : splitScheduledUnscheduled(filtered)),
|
if (advActive) {
|
||||||
[advActive, advResults, filtered],
|
const split = splitScheduledUnscheduled(advResults);
|
||||||
);
|
return { unassigned: [], ...split };
|
||||||
|
}
|
||||||
|
return composeBoardSections({
|
||||||
|
displayRows,
|
||||||
|
weekRows: filtered,
|
||||||
|
weeklyIds,
|
||||||
|
queueIds,
|
||||||
|
});
|
||||||
|
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
|
||||||
|
|
||||||
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
||||||
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
||||||
|
|
@ -49,7 +80,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
const searchActive = filters.search.trim() !== "";
|
const searchActive = filters.search.trim() !== "";
|
||||||
const visibleRows = advActive ? advResults : filtered;
|
const visibleRows = advActive ? advResults : filtered;
|
||||||
const noSearchResults = visibleRows.length === 0 && searchActive;
|
const noSearchResults = visibleRows.length === 0 && searchActive;
|
||||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
const isEmptyWeek =
|
||||||
|
!advActive &&
|
||||||
|
scheduled.length === 0 &&
|
||||||
|
unassigned.length === 0 &&
|
||||||
|
unscheduled.length === 0 &&
|
||||||
|
!searchActive;
|
||||||
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
||||||
|
|
||||||
const todayKey =
|
const todayKey =
|
||||||
|
|
@ -70,6 +106,9 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
refetch,
|
refetch,
|
||||||
advActive,
|
advActive,
|
||||||
advResults,
|
advResults,
|
||||||
|
unassigned,
|
||||||
|
unassignedError,
|
||||||
|
unassignedLoading,
|
||||||
unscheduled,
|
unscheduled,
|
||||||
displayGroups,
|
displayGroups,
|
||||||
searchActive,
|
searchActive,
|
||||||
|
|
@ -79,6 +118,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
draftDayKey: local.draftDayKey,
|
draftDayKey: local.draftDayKey,
|
||||||
draft: local.draft,
|
draft: local.draft,
|
||||||
setDraft: local.setDraft,
|
setDraft: local.setDraft,
|
||||||
|
unassignedOpen: local.unassignedOpen,
|
||||||
|
setUnassignedOpen: local.setUnassignedOpen,
|
||||||
unschedOpen: local.unschedOpen,
|
unschedOpen: local.unschedOpen,
|
||||||
setUnschedOpen: local.setUnschedOpen,
|
setUnschedOpen: local.setUnschedOpen,
|
||||||
selectedId: local.selectedId,
|
selectedId: local.selectedId,
|
||||||
|
|
@ -99,7 +140,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
scopeTotalCount: data?.totalCount ?? 0,
|
scopeTotalCount: data?.totalCount ?? 0,
|
||||||
totalCount: advActive
|
totalCount: advActive
|
||||||
? (data?.totalCount ?? advResults.length)
|
? (data?.totalCount ?? advResults.length)
|
||||||
: (data?.returnedCount ?? visibleRows.length),
|
: (data?.returnedCount ?? weeklyItems.length),
|
||||||
search: filters.search,
|
search: filters.search,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,8 @@ export function useWorkOrdersTableLocalState({
|
||||||
setDraft: ui.setDraft,
|
setDraft: ui.setDraft,
|
||||||
startDayDraft: ui.startDayDraft,
|
startDayDraft: ui.startDayDraft,
|
||||||
cancelDraft: ui.cancelDraft,
|
cancelDraft: ui.cancelDraft,
|
||||||
|
unassignedOpen: ui.unassignedOpen,
|
||||||
|
setUnassignedOpen: ui.setUnassignedOpen,
|
||||||
unschedOpen: ui.unschedOpen,
|
unschedOpen: ui.unschedOpen,
|
||||||
setUnschedOpen: ui.setUnschedOpen,
|
setUnschedOpen: ui.setUnschedOpen,
|
||||||
selectedId: ui.selectedId,
|
selectedId: ui.selectedId,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
EMPTY_TABLE_DRAFT,
|
EMPTY_TABLE_DRAFT,
|
||||||
|
UNASSIGNED_DAY_KEY,
|
||||||
UNSCHEDULED_DAY_KEY,
|
UNSCHEDULED_DAY_KEY,
|
||||||
type WorkOrderTableDraft,
|
type WorkOrderTableDraft,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
@ -11,7 +12,7 @@ export type DragVizState = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: string): string {
|
function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: string): string {
|
||||||
if (key === UNSCHEDULED_DAY_KEY) {
|
if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) {
|
if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) {
|
||||||
|
|
@ -31,10 +32,11 @@ function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Draft, selection, unsched accordion, drag viz and flash scroll helpers. */
|
/** Draft, selection, pinned-section accordions, drag viz and flash scroll helpers. */
|
||||||
export function useWorkOrdersTableUiState(weekMonday: string) {
|
export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||||
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
||||||
|
const [unassignedOpen, setUnassignedOpen] = useState(true);
|
||||||
const [unschedOpen, setUnschedOpen] = useState(true);
|
const [unschedOpen, setUnschedOpen] = useState(true);
|
||||||
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
||||||
const [flashId, setFlashId] = useState<string | number | null>(null);
|
const [flashId, setFlashId] = useState<string | number | null>(null);
|
||||||
|
|
@ -50,7 +52,7 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||||
(key: string, scheduledIso?: string) => {
|
(key: string, scheduledIso?: string) => {
|
||||||
setDraftDayKey(key);
|
setDraftDayKey(key);
|
||||||
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
|
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
|
||||||
if (key === UNSCHEDULED_DAY_KEY) {
|
if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) {
|
||||||
setDraft({ ...EMPTY_TABLE_DRAFT });
|
setDraft({ ...EMPTY_TABLE_DRAFT });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +87,8 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||||
setDraft,
|
setDraft,
|
||||||
startDayDraft,
|
startDayDraft,
|
||||||
cancelDraft,
|
cancelDraft,
|
||||||
|
unassignedOpen,
|
||||||
|
setUnassignedOpen,
|
||||||
unschedOpen,
|
unschedOpen,
|
||||||
setUnschedOpen,
|
setUnschedOpen,
|
||||||
selectedId,
|
selectedId,
|
||||||
|
|
|
||||||
29
src/domain/work-orders/api/fetch-unassigned-board-rows.ts
Normal file
29
src/domain/work-orders/api/fetch-unassigned-board-rows.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { toUnassignedQueueQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
|
||||||
|
const UNASSIGNED_QUEUE_MAX_PAGES = 50;
|
||||||
|
|
||||||
|
/** Exhausts GET /board/search pages for the global Unassigned pin (SH-165). */
|
||||||
|
export async function fetchUnassignedBoardRows(): Promise<WorkOrderTableRow[]> {
|
||||||
|
const rows: WorkOrderTableRow[] = [];
|
||||||
|
let page = 0;
|
||||||
|
let totalCount = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
while (rows.length < totalCount) {
|
||||||
|
if (page >= UNASSIGNED_QUEUE_MAX_PAGES) {
|
||||||
|
throw new Error(
|
||||||
|
"Unable to load the unassigned queue: search result set exceeded the safety page limit.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page));
|
||||||
|
rows.push(...result.items);
|
||||||
|
totalCount = result.totalCount;
|
||||||
|
if (result.items.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import type {
|
import type {
|
||||||
CompletionDocStatus,
|
CompletionDocStatus,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
|
WorkOrderUpliftSummary,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
@ -215,6 +216,55 @@ function readFlagColor(record: Record<string, unknown>): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readUpliftSummary(item: Record<string, unknown>): WorkOrderUpliftSummary | undefined {
|
||||||
|
const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
|
||||||
|
const hasNested = Object.keys(nested).length > 0;
|
||||||
|
const source = hasNested ? nested : item;
|
||||||
|
|
||||||
|
// Board/search/detail contract (SH-188): pendingUpliftCount is authoritative when present.
|
||||||
|
const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
|
||||||
|
const nestedCount = readNumber(source, "pendingCount", "PendingCount");
|
||||||
|
const pendingCount = nestedCount ?? boardCount ?? 0;
|
||||||
|
const hasUplift =
|
||||||
|
readBool(source, "hasUplift", "HasUplift") ||
|
||||||
|
readBool(item, "hasUplift", "HasUplift") ||
|
||||||
|
pendingCount > 0;
|
||||||
|
|
||||||
|
// No nested summary and no board count → omit (legacy payloads without the contract field).
|
||||||
|
if (!hasNested && boardCount == null && !hasUplift) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: WorkOrderUpliftSummary = {
|
||||||
|
hasUplift: hasUplift || pendingCount > 0,
|
||||||
|
pendingCount,
|
||||||
|
};
|
||||||
|
const primaryStatus =
|
||||||
|
readString(nested, "primaryStatus", "PrimaryStatus") ||
|
||||||
|
readString(item, "primaryStatus", "PrimaryStatus");
|
||||||
|
if (primaryStatus) {
|
||||||
|
summary.primaryStatus = primaryStatus;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHasPendingUplift(
|
||||||
|
item: Record<string, unknown>,
|
||||||
|
upliftSummary: WorkOrderUpliftSummary | undefined,
|
||||||
|
): boolean | undefined {
|
||||||
|
if ("hasPendingUplift" in item || "HasPendingUplift" in item) {
|
||||||
|
return Boolean(item.hasPendingUplift ?? item.HasPendingUplift);
|
||||||
|
}
|
||||||
|
const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
|
||||||
|
if (boardCount != null) {
|
||||||
|
return boardCount > 0;
|
||||||
|
}
|
||||||
|
if (upliftSummary != null) {
|
||||||
|
return upliftSummary.pendingCount > 0;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const scheduledOn = toDateInputValue(
|
const scheduledOn = toDateInputValue(
|
||||||
|
|
@ -269,6 +319,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
const locationIdRaw = item.locationId ?? item.LocationId;
|
const locationIdRaw = item.locationId ?? item.LocationId;
|
||||||
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
||||||
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
||||||
|
const upliftSummary = readUpliftSummary(item);
|
||||||
|
const hasPendingUplift = readHasPendingUplift(item, upliftSummary);
|
||||||
|
|
||||||
const row: WorkOrderTableRow = {
|
const row: WorkOrderTableRow = {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
|
|
@ -279,7 +331,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
dispatcherId,
|
dispatcherId,
|
||||||
dispatcherName,
|
dispatcherName,
|
||||||
scheduledOn,
|
scheduledOn,
|
||||||
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
|
completedDate: toDateInputValue(
|
||||||
|
readString(item, "completedDate", "CompletedDate", "completedOn", "CompletedOn"),
|
||||||
|
),
|
||||||
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
||||||
dayGroup,
|
dayGroup,
|
||||||
dayLabel,
|
dayLabel,
|
||||||
|
|
@ -316,6 +370,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
||||||
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
||||||
flagColor: readFlagColor(item),
|
flagColor: readFlagColor(item),
|
||||||
|
upliftSummary,
|
||||||
|
hasPendingUplift,
|
||||||
};
|
};
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,18 @@ export const LIFECYCLE_STATUS_API = {
|
||||||
|
|
||||||
export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API];
|
export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API];
|
||||||
|
|
||||||
|
/** Non-terminal lifecycle statuses for the global Unassigned queue (SH-165). */
|
||||||
|
export const NON_TERMINAL_LIFECYCLE_STATUSES: LifecycleStatusApi[] = [
|
||||||
|
LIFECYCLE_STATUS_API.Incomplete,
|
||||||
|
LIFECYCLE_STATUS_API.Pending,
|
||||||
|
LIFECYCLE_STATUS_API.Scheduled,
|
||||||
|
LIFECYCLE_STATUS_API["En Route"],
|
||||||
|
LIFECYCLE_STATUS_API["On Site"],
|
||||||
|
LIFECYCLE_STATUS_API["In Progress"],
|
||||||
|
LIFECYCLE_STATUS_API.Rescheduled,
|
||||||
|
LIFECYCLE_STATUS_API["Pending Quote"],
|
||||||
|
];
|
||||||
|
|
||||||
/** Maps API type enums to selectable FE types. Legacy AddOn(7) is not a type. */
|
/** Maps API type enums to selectable FE types. Legacy AddOn(7) is not a type. */
|
||||||
export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
||||||
[WORK_ORDER_TYPE_API.PM]: "PM",
|
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||||
|
|
@ -179,6 +191,11 @@ export interface WorkOrderBoardRowApi {
|
||||||
primaryDispatchId?: number | null;
|
primaryDispatchId?: number | null;
|
||||||
rowVersion?: string | null;
|
rowVersion?: string | null;
|
||||||
dispatchRowVersion?: string | null;
|
dispatchRowVersion?: string | null;
|
||||||
|
/**
|
||||||
|
* Authoritative pending-uplift count across all dispatches for this WO (SH-188 / BE board contract).
|
||||||
|
* Drives the Completed closability gate.
|
||||||
|
*/
|
||||||
|
pendingUpliftCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderBoardCountsApi {
|
export interface WorkOrderBoardCountsApi {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||||
|
|
||||||
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
|
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
export type WorkOrderUpliftSummary = {
|
||||||
|
hasUplift: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
primaryStatus?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface WorkOrderTableRow {
|
export interface WorkOrderTableRow {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
woNumber: string;
|
woNumber: string;
|
||||||
|
|
@ -15,7 +21,7 @@ export interface WorkOrderTableRow {
|
||||||
dispatcherId: string;
|
dispatcherId: string;
|
||||||
dispatcherName: string;
|
dispatcherName: string;
|
||||||
scheduledOn: string;
|
scheduledOn: string;
|
||||||
/** Work completed date (API CompletedDate); distinct from scheduledOn. */
|
/** Work completed date (API CompletedDate); used by closability / COMPLETED DATE column. */
|
||||||
completedDate: string;
|
completedDate: string;
|
||||||
targetWeek: string;
|
targetWeek: string;
|
||||||
dayGroup: string;
|
dayGroup: string;
|
||||||
|
|
@ -59,6 +65,8 @@ export interface WorkOrderTableRow {
|
||||||
dispatchRowVersion: string;
|
dispatchRowVersion: string;
|
||||||
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
||||||
flagColor: string | null;
|
flagColor: string | null;
|
||||||
|
upliftSummary?: WorkOrderUpliftSummary | null;
|
||||||
|
hasPendingUplift?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderTableDraft {
|
export interface WorkOrderTableDraft {
|
||||||
|
|
@ -130,3 +138,4 @@ export type WorkOrderDayGroup = {
|
||||||
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
||||||
|
|
||||||
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
|
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
|
||||||
|
export const UNASSIGNED_DAY_KEY = "__unassigned__" as const;
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export type {
|
||||||
|
|
||||||
export {
|
export {
|
||||||
EMPTY_TABLE_DRAFT,
|
EMPTY_TABLE_DRAFT,
|
||||||
|
UNASSIGNED_DAY_KEY,
|
||||||
UNSCHEDULED_DAY_KEY,
|
UNSCHEDULED_DAY_KEY,
|
||||||
WORK_ORDERS_TABLE_PAGE_SIZE,
|
WORK_ORDERS_TABLE_PAGE_SIZE,
|
||||||
} from "./work-order-table-row";
|
} from "./work-order-table-row";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
|
||||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
import type {
|
import type {
|
||||||
WorkOrdersListParams,
|
WorkOrdersListParams,
|
||||||
|
|
@ -10,6 +11,7 @@ import type {
|
||||||
WorkOrderBoardQueryParams,
|
WorkOrderBoardQueryParams,
|
||||||
WorkOrderBoardSearchQueryParams,
|
WorkOrderBoardSearchQueryParams,
|
||||||
} from "@/domain/work-orders/types/work-order-board";
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export function useWorkOrdersList(
|
export function useWorkOrdersList(
|
||||||
|
|
@ -46,6 +48,17 @@ export function useWorkOrdersBoardSearch(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useWorkOrdersUnassignedQueue(
|
||||||
|
enabled = true,
|
||||||
|
): UseQueryResult<WorkOrderTableRow[], Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.workOrders.unassignedQueue(),
|
||||||
|
queryFn: fetchUnassignedBoardRows,
|
||||||
|
enabled,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.workOrders.dispatchers(),
|
queryKey: queryKeys.workOrders.dispatchers(),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DATE_RANGE_TO_PRESET,
|
DATE_RANGE_TO_PRESET,
|
||||||
DOC_STATUS_API,
|
DOC_STATUS_API,
|
||||||
LIFECYCLE_STATUS_API,
|
LIFECYCLE_STATUS_API,
|
||||||
|
NON_TERMINAL_LIFECYCLE_STATUSES,
|
||||||
type WorkOrderBoardQueryParams,
|
type WorkOrderBoardQueryParams,
|
||||||
type WorkOrderBoardSearchQueryParams,
|
type WorkOrderBoardSearchQueryParams,
|
||||||
} from "@/domain/work-orders/types/work-order-board";
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
|
|
@ -23,6 +24,14 @@ export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
|
||||||
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
|
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
|
||||||
export const ADVANCED_SEARCH_ALL_WEEKS_TO = "2099-12-31";
|
export const ADVANCED_SEARCH_ALL_WEEKS_TO = "2099-12-31";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wide Custom window for SH-165. GET /board/search defaults omitted
|
||||||
|
* `datePreset` to ThisWeek (`WorkOrderSearchDateRangeResolver`), which would
|
||||||
|
* hide scheduled-unassigned WOs outside the current week.
|
||||||
|
*/
|
||||||
|
export const UNASSIGNED_QUEUE_DATE_FROM = "1970-01-01";
|
||||||
|
export const UNASSIGNED_QUEUE_DATE_TO = "2099-12-31";
|
||||||
|
|
||||||
function toBoardDispatcherId(id: string): string {
|
function toBoardDispatcherId(id: string): string {
|
||||||
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
|
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
|
||||||
return BOARD_DISPATCHER_UNASSIGNED;
|
return BOARD_DISPATCHER_UNASSIGNED;
|
||||||
|
|
@ -235,3 +244,16 @@ export function buildBoardSearchParamsRecord(
|
||||||
export function woTypeFilterIsOverdue(type: string): boolean {
|
export function woTypeFilterIsOverdue(type: string): boolean {
|
||||||
return type === "Overdue";
|
return type === "Overdue";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
|
||||||
|
export function toUnassignedQueueQueryParams(page = 0): WorkOrderBoardSearchQueryParams {
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
|
dispatchers: [BOARD_DISPATCHER_UNASSIGNED],
|
||||||
|
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
|
||||||
|
datePreset: "Custom",
|
||||||
|
dateFrom: UNASSIGNED_QUEUE_DATE_FROM,
|
||||||
|
dateTo: UNASSIGNED_QUEUE_DATE_TO,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,78 @@ export function filterByBarAndSearch(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUnassignedBoardRow(row: WorkOrderTableRow): boolean {
|
||||||
|
if (TERMINAL_STATUSES.has(row.status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !row.dispatcherId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partitionBoardRows(rows: WorkOrderTableRow[]): {
|
||||||
|
unassigned: WorkOrderTableRow[];
|
||||||
|
unscheduled: WorkOrderTableRow[];
|
||||||
|
scheduled: WorkOrderTableRow[];
|
||||||
|
} {
|
||||||
|
const unassigned: WorkOrderTableRow[] = [];
|
||||||
|
const remaining: WorkOrderTableRow[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (isUnassignedBoardRow(row)) {
|
||||||
|
unassigned.push(row);
|
||||||
|
} else {
|
||||||
|
remaining.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scheduled, unscheduled } = splitScheduledUnscheduled(remaining);
|
||||||
|
return { unassigned, unscheduled, scheduled };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowIdSet(rows: WorkOrderTableRow[]): Set<string> {
|
||||||
|
return new Set(rows.map((row) => String(row.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weekly board rows win on id overlap so PATCH state stays authoritative. */
|
||||||
|
export function mergeWeeklyAndUnassignedQueue(
|
||||||
|
weekly: WorkOrderTableRow[],
|
||||||
|
queue: WorkOrderTableRow[],
|
||||||
|
): WorkOrderTableRow[] {
|
||||||
|
if (queue.length === 0) {
|
||||||
|
return weekly;
|
||||||
|
}
|
||||||
|
const seen = rowIdSet(weekly);
|
||||||
|
const extras = queue.filter((row) => !seen.has(String(row.id)));
|
||||||
|
return extras.length === 0 ? weekly : [...weekly, ...extras];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComposeBoardSectionsInput = {
|
||||||
|
displayRows: WorkOrderTableRow[];
|
||||||
|
weekRows: WorkOrderTableRow[];
|
||||||
|
weeklyIds: ReadonlySet<string>;
|
||||||
|
queueIds: ReadonlySet<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin is every non-terminal unassigned row (including out-of-week).
|
||||||
|
* Day/Unscheduled groups stay week-scoped and never repeat pin ids.
|
||||||
|
*/
|
||||||
|
export function composeBoardSections(input: ComposeBoardSectionsInput): {
|
||||||
|
unassigned: WorkOrderTableRow[];
|
||||||
|
unscheduled: WorkOrderTableRow[];
|
||||||
|
scheduled: WorkOrderTableRow[];
|
||||||
|
} {
|
||||||
|
const unassigned = input.displayRows.filter(isUnassignedBoardRow);
|
||||||
|
const pinIds = rowIdSet(unassigned);
|
||||||
|
const weekAssigned = input.weekRows.filter((row) => {
|
||||||
|
const id = String(row.id);
|
||||||
|
if (pinIds.has(id) || isUnassignedBoardRow(row)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return input.weeklyIds.has(id) || !input.queueIds.has(id);
|
||||||
|
});
|
||||||
|
return { unassigned, ...splitScheduledUnscheduled(weekAssigned) };
|
||||||
|
}
|
||||||
|
|
||||||
export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
|
export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
|
||||||
scheduled: WorkOrderTableRow[];
|
scheduled: WorkOrderTableRow[];
|
||||||
unscheduled: WorkOrderTableRow[];
|
unscheduled: WorkOrderTableRow[];
|
||||||
|
|
|
||||||
148
src/domain/work-orders/utils/wo-closability.ts
Normal file
148
src/domain/work-orders/utils/wo-closability.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import type {
|
||||||
|
CompletionDocStatus,
|
||||||
|
WorkOrderUpliftSummary,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */
|
||||||
|
export type WoClosabilityInput = {
|
||||||
|
dispatcherId: string;
|
||||||
|
dispatcherName: string;
|
||||||
|
/** Company / vendor assignment — required for Completed (SH-183). */
|
||||||
|
company: string;
|
||||||
|
/** Technician is optional for closability; modeled separately from company. */
|
||||||
|
tech: string;
|
||||||
|
/** Work completed date (API CompletedDate). Empty until set. */
|
||||||
|
completedDate: string;
|
||||||
|
woNumber: string;
|
||||||
|
pm: string;
|
||||||
|
docStatus: CompletionDocStatus;
|
||||||
|
mediaCount: number;
|
||||||
|
type: WOType | "";
|
||||||
|
upliftSummary?: WorkOrderUpliftSummary | null;
|
||||||
|
hasPendingUplift?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PENDING_UPLIFT_GAP = "Pending uplift approval";
|
||||||
|
|
||||||
|
/** SH-188 tooltip when Completed is blocked solely by a pending uplift. */
|
||||||
|
export const UPLIFT_PENDING_COMPLETED_TITLE =
|
||||||
|
"This work order has an uplift pending approval and cannot be completed.";
|
||||||
|
|
||||||
|
/** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */
|
||||||
|
export function isRealWoNumber(woNumber: string): boolean {
|
||||||
|
const trimmed = woNumber.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !/^SH/i.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPendingUplift(
|
||||||
|
row: Pick<WoClosabilityInput, "upliftSummary" | "hasPendingUplift">,
|
||||||
|
): boolean {
|
||||||
|
if (row.hasPendingUplift === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (row.upliftSummary?.pendingCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatcherName">): boolean {
|
||||||
|
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompany(company: string): boolean {
|
||||||
|
return Boolean(company.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasService(pm: string): boolean {
|
||||||
|
return Boolean(pm.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompletionDocument(docStatus: CompletionDocStatus): boolean {
|
||||||
|
return docStatus === "Yes" || docStatus === "NN";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gaps that block Completed, in design hierarchy:
|
||||||
|
* fields → uplift pending → completion document / photos.
|
||||||
|
* (Scheduled-date gate is a separate design P1 — not in this module yet.)
|
||||||
|
*/
|
||||||
|
export function getClosabilityGaps(row: WoClosabilityInput): string[] {
|
||||||
|
const gaps: string[] = [];
|
||||||
|
|
||||||
|
if (!hasDispatcher(row)) {
|
||||||
|
gaps.push("Dispatcher");
|
||||||
|
}
|
||||||
|
if (!hasCompany(row.company)) {
|
||||||
|
gaps.push("Company");
|
||||||
|
}
|
||||||
|
if (!row.completedDate.trim()) {
|
||||||
|
gaps.push("Completed Date");
|
||||||
|
}
|
||||||
|
if (!isRealWoNumber(row.woNumber)) {
|
||||||
|
gaps.push("WO number");
|
||||||
|
}
|
||||||
|
if (!hasService(row.pm)) {
|
||||||
|
gaps.push("Service");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authoritative pending uplift (board pendingUpliftCount / hasPendingUplift) blocks completion.
|
||||||
|
// Missing uplift fields mean no pending evidence until the board contract supplies the count.
|
||||||
|
if (hasPendingUplift(row)) {
|
||||||
|
gaps.push(PENDING_UPLIFT_GAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === "PM" || row.type === "Overdue") {
|
||||||
|
if (!hasCompletionDocument(row.docStatus)) {
|
||||||
|
gaps.push("Completion document");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === "Emergency" || row.type === "Reactive") {
|
||||||
|
if (row.mediaCount < 1) {
|
||||||
|
gaps.push("At least 1 photo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatClosabilityMissingTitle(gaps: string[]): string {
|
||||||
|
if (gaps.includes(PENDING_UPLIFT_GAP)) {
|
||||||
|
return UPLIFT_PENDING_COMPLETED_TITLE;
|
||||||
|
}
|
||||||
|
return `Missing: ${gaps.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompDocGateResult = { allowed: true } | { allowed: false; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate for completion-document generation. CompDocDialog (not yet on `dev`) should call this
|
||||||
|
* before opening generate flow and surface `reason` when blocked.
|
||||||
|
*
|
||||||
|
* Intentionally omits the "Completion document" closability gap — generate is how that gap is filled.
|
||||||
|
*/
|
||||||
|
export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult {
|
||||||
|
const gaps = getClosabilityGaps(row).filter((gap) => gap !== "Completion document");
|
||||||
|
if (gaps.length > 0) {
|
||||||
|
return { allowed: false, reason: formatClosabilityMissingTitle(gaps) };
|
||||||
|
}
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map create-wizard draft onto the shared closability contract. */
|
||||||
|
export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput {
|
||||||
|
return {
|
||||||
|
dispatcherId: draft.assigneeId,
|
||||||
|
dispatcherName: draft.assigneeName,
|
||||||
|
company: draft.vendorName,
|
||||||
|
tech: "",
|
||||||
|
completedDate: "",
|
||||||
|
woNumber: draft.woNumber,
|
||||||
|
pm: draft.pm,
|
||||||
|
docStatus: "",
|
||||||
|
mediaCount: 0,
|
||||||
|
type: draft.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ export const queryKeys = {
|
||||||
[...queryKeys.workOrders.all, "board", params ?? {}] as const,
|
[...queryKeys.workOrders.all, "board", params ?? {}] as const,
|
||||||
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
|
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
|
||||||
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
|
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
|
||||||
|
unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const,
|
||||||
byLocation: (locationId: string | number, search = "") =>
|
byLocation: (locationId: string | number, search = "") =>
|
||||||
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
|
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
|
||||||
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
woNumber: "1001",
|
||||||
|
site: "BK5",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Alex",
|
||||||
|
scheduledOn: "2026-07-15",
|
||||||
|
completedDate: "2026-07-20",
|
||||||
|
targetWeek: "2026-07-15",
|
||||||
|
dayGroup: "07/15",
|
||||||
|
dayLabel: "Tue",
|
||||||
|
location: "Lobby",
|
||||||
|
locationId: "loc-1",
|
||||||
|
pm: "HVAC",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "7",
|
||||||
|
company: "Acme",
|
||||||
|
tech: "Sam",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "In Progress",
|
||||||
|
docStatus: "Yes",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
isAddOn: false,
|
||||||
|
mediaCount: 1,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StatusCell pending uplift closability", () => {
|
||||||
|
it("disables Completed with tooltip when uplift is pending", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({ hasPendingUplift: true })}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
|
||||||
|
const completed = screen.getByRole("button", { name: /completed/i });
|
||||||
|
expect(completed).toBeDisabled();
|
||||||
|
expect(completed).toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
"This work order has an uplift pending approval and cannot be completed.",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(completed);
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Completed with tooltip when board pendingUpliftCount maps to pending", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({
|
||||||
|
hasPendingUplift: true,
|
||||||
|
upliftSummary: { hasUplift: true, pendingCount: 1 },
|
||||||
|
})}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
|
||||||
|
const completed = screen.getByRole("button", { name: /completed/i });
|
||||||
|
expect(completed).toBeDisabled();
|
||||||
|
expect(completed).toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
"This work order has an uplift pending approval and cannot be completed.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Completed when no pending uplift", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell wo={makeRow()} onChangeStatus={onChangeStatus} onOpenSlideOver={vi.fn()} />,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
|
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Completed when company is missing (SH-183)", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({ company: "" })}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
|
||||||
|
const completed = screen.getByRole("button", { name: /completed/i });
|
||||||
|
expect(completed).toBeDisabled();
|
||||||
|
expect(completed).toHaveAttribute("title", "Missing: Company");
|
||||||
|
fireEvent.click(completed);
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Completed when technician is empty if company is set (SH-183)", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({ tech: "" })}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
|
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,412 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
|
||||||
|
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
|
||||||
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
||||||
|
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
buildDayGroups,
|
||||||
|
composeBoardSections,
|
||||||
|
rowIdSet,
|
||||||
|
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: "1",
|
||||||
|
woNumber: "24991",
|
||||||
|
site: "CLV2",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
scheduledOn: "2026-06-02",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "06/02",
|
||||||
|
dayLabel: "TUE · 06/02",
|
||||||
|
location: "Cleveland, OH",
|
||||||
|
locationId: "",
|
||||||
|
pm: "Backflow",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
company: "",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "—",
|
||||||
|
status: "Incomplete",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
isAddOn: false,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
vendorId: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseTableData(
|
||||||
|
overrides: Partial<ReturnTypeOfUseWorkOrdersTableData> = {},
|
||||||
|
): ReturnTypeOfUseWorkOrdersTableData {
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
advActive: false,
|
||||||
|
advResults: [],
|
||||||
|
unassigned: [],
|
||||||
|
unassignedError: null,
|
||||||
|
unassignedLoading: false,
|
||||||
|
unscheduled: [],
|
||||||
|
displayGroups: [],
|
||||||
|
searchActive: false,
|
||||||
|
noSearchResults: false,
|
||||||
|
isEmptyWeek: false,
|
||||||
|
todayKey: null,
|
||||||
|
draftDayKey: null,
|
||||||
|
draft: null,
|
||||||
|
setDraft: vi.fn(),
|
||||||
|
unassignedOpen: true,
|
||||||
|
setUnassignedOpen: vi.fn(),
|
||||||
|
unschedOpen: true,
|
||||||
|
setUnschedOpen: vi.fn(),
|
||||||
|
selectedId: null,
|
||||||
|
setSelectedId: vi.fn(),
|
||||||
|
selectedRow: null,
|
||||||
|
flashId: null,
|
||||||
|
triggerFlash: vi.fn(),
|
||||||
|
dragViz: null,
|
||||||
|
setDragViz: vi.fn(),
|
||||||
|
dragState: null,
|
||||||
|
patchRow: vi.fn(),
|
||||||
|
clearRowPatch: vi.fn(),
|
||||||
|
reorderRows: vi.fn(),
|
||||||
|
endDrag: vi.fn(),
|
||||||
|
startDayDraft: vi.fn(),
|
||||||
|
cancelDraft: vi.fn(),
|
||||||
|
addRow: vi.fn(),
|
||||||
|
scopeTotalCount: 0,
|
||||||
|
totalCount: 0,
|
||||||
|
search: "",
|
||||||
|
...overrides,
|
||||||
|
} as ReturnTypeOfUseWorkOrdersTableData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" };
|
||||||
|
|
||||||
|
function UnassignedAssignHarness({
|
||||||
|
initialRow,
|
||||||
|
patchBoardField,
|
||||||
|
}: {
|
||||||
|
initialRow: WorkOrderTableRow;
|
||||||
|
patchBoardField: (
|
||||||
|
id: string | number,
|
||||||
|
payload: { field: string; value?: string | null },
|
||||||
|
) => Promise<WorkOrderTableRow>;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState([initialRow]);
|
||||||
|
const weeklyIds = rowIdSet(rows);
|
||||||
|
const sections = useMemo(
|
||||||
|
() =>
|
||||||
|
composeBoardSections({
|
||||||
|
displayRows: rows,
|
||||||
|
weekRows: rows,
|
||||||
|
weeklyIds,
|
||||||
|
queueIds: weeklyIds,
|
||||||
|
}),
|
||||||
|
[rows, weeklyIds],
|
||||||
|
);
|
||||||
|
const tableData = baseTableData({
|
||||||
|
unassigned: sections.unassigned,
|
||||||
|
unscheduled: sections.unscheduled,
|
||||||
|
displayGroups: buildDayGroups(sections.scheduled),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table>
|
||||||
|
<WoTableBody
|
||||||
|
tableData={tableData}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||||
|
<td>{row.woNumber}</td>
|
||||||
|
<td>
|
||||||
|
<AssignedToCell
|
||||||
|
dispatcherId={row.dispatcherId}
|
||||||
|
users={[DISPATCHER]}
|
||||||
|
highlight={!row.dispatcherId}
|
||||||
|
onSave={async (id, name) => {
|
||||||
|
const ops = expandTablePatchToBoardFieldOps({
|
||||||
|
dispatcherId: id,
|
||||||
|
dispatcherName: name,
|
||||||
|
});
|
||||||
|
const patched = await patchBoardField(row.id, ops[0]!);
|
||||||
|
setRows((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
String(item.id) === String(row.id)
|
||||||
|
? { ...patched, dispatcherId: id, dispatcherName: name }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
renderInlineRow={() => null}
|
||||||
|
/>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WoTableBody Unassigned queue (SH-165)", () => {
|
||||||
|
it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => {
|
||||||
|
const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
|
||||||
|
const undatedUnassigned = makeRow({
|
||||||
|
id: "us1",
|
||||||
|
woNumber: "25021",
|
||||||
|
scheduledOn: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
});
|
||||||
|
const undatedAssigned = makeRow({
|
||||||
|
id: "us-assigned",
|
||||||
|
woNumber: "25022",
|
||||||
|
dispatcherId: "sarah",
|
||||||
|
dispatcherName: "Sarah",
|
||||||
|
scheduledOn: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
});
|
||||||
|
const datedAssigned = makeRow({
|
||||||
|
id: "day-assigned",
|
||||||
|
woNumber: "25023",
|
||||||
|
dispatcherId: "sarah",
|
||||||
|
dispatcherName: "Sarah",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableData = baseTableData({
|
||||||
|
unassigned: [datedUnassigned, undatedUnassigned],
|
||||||
|
unscheduled: [undatedAssigned],
|
||||||
|
displayGroups: [
|
||||||
|
{
|
||||||
|
key: "06/02",
|
||||||
|
label: "TUE · 06/02",
|
||||||
|
date: "2026-06-02",
|
||||||
|
items: [datedAssigned],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<WoTableBody
|
||||||
|
tableData={tableData}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||||
|
<td>{row.woNumber}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
renderInlineRow={() => null}
|
||||||
|
/>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const unassignedHeader = screen.getByRole("button", {
|
||||||
|
name: "Unassigned, 2 work orders, expanded",
|
||||||
|
});
|
||||||
|
const unscheduledHeader = screen.getByRole("button", {
|
||||||
|
name: "Unscheduled, 1 work order, expanded",
|
||||||
|
});
|
||||||
|
const dayLabel = screen.getByText("TUE · 06/02");
|
||||||
|
|
||||||
|
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("row-us1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("row-us-assigned")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByTestId(/row-/)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses and expands the Unassigned section via the disclosure control", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const setUnassignedOpen = vi.fn();
|
||||||
|
const tableData = baseTableData({
|
||||||
|
unassigned: [makeRow({ id: "u1", woNumber: "24991" })],
|
||||||
|
setUnassignedOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<WoTableBody
|
||||||
|
tableData={tableData}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||||
|
<td>{row.woNumber}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
renderInlineRow={() => null}
|
||||||
|
/>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" }));
|
||||||
|
expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a scheduled unassigned WO from outside the selected week in the pin", () => {
|
||||||
|
const outOfWeek = makeRow({
|
||||||
|
id: "out-week",
|
||||||
|
woNumber: "26001",
|
||||||
|
scheduledOn: "2026-07-15",
|
||||||
|
dayGroup: "07/15",
|
||||||
|
dayLabel: "WED · 07/15",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<WoTableBody
|
||||||
|
tableData={baseTableData({ unassigned: [outOfWeek] })}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||||
|
<td>{row.woNumber}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
renderInlineRow={() => null}
|
||||||
|
/>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("row-out-week")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes a still-loading queue from an empty one", () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<WoTableBody
|
||||||
|
tableData={baseTableData({ unassignedLoading: true })}
|
||||||
|
renderRow={() => null}
|
||||||
|
renderInlineRow={() => null}
|
||||||
|
/>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Loading unassigned work orders/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No unassigned work orders.")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces an empty queue only once loading has settled", () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<WoTableBody
|
||||||
|
tableData={baseTableData({ unassignedLoading: false })}
|
||||||
|
renderRow={() => null}
|
||||||
|
renderInlineRow={() => null}
|
||||||
|
/>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Unassigned pin visible when the queue query fails", () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<WoTableBody
|
||||||
|
tableData={baseTableData({
|
||||||
|
unassignedError: new Error("queue unavailable"),
|
||||||
|
})}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||||
|
<td>{row.woNumber}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
renderInlineRow={() => null}
|
||||||
|
/>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("queue unavailable")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Unassigned section visible with an empty state when the queue is empty", () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<WoTableBody
|
||||||
|
tableData={baseTableData()}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||||
|
<td>{row.woNumber}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
renderInlineRow={() => null}
|
||||||
|
/>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a pinned row immediately after assigning through the board PATCH path", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const pinned = makeRow({
|
||||||
|
id: "u-pin",
|
||||||
|
woNumber: "24991",
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
rowVersion: "AAAAAAA=",
|
||||||
|
});
|
||||||
|
const patchBoardField = vi.fn(async (_id: string | number, payload: { field: string }) => {
|
||||||
|
expect(payload.field).toBe("assignTo");
|
||||||
|
return { ...pinned, dispatcherId: DISPATCHER.id, dispatcherName: DISPATCHER.name };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UnassignedAssignHarness initialRow={pinned} patchBoardField={patchBoardField} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: /Select/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /Sarah Chen/ }));
|
||||||
|
|
||||||
|
expect(patchBoardField).toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -76,6 +76,11 @@ const handlers: WoTableRowHandlers = {
|
||||||
function renderUnscheduledHarness() {
|
function renderUnscheduledHarness() {
|
||||||
const tableData = {
|
const tableData = {
|
||||||
advActive: false,
|
advActive: false,
|
||||||
|
unassigned: [],
|
||||||
|
unassignedError: null,
|
||||||
|
unassignedLoading: false,
|
||||||
|
unassignedOpen: true,
|
||||||
|
setUnassignedOpen: vi.fn(),
|
||||||
unscheduled: [unscheduledRow],
|
unscheduled: [unscheduledRow],
|
||||||
unschedOpen: true,
|
unschedOpen: true,
|
||||||
draftDayKey: null,
|
draftDayKey: null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { BOARD_SEARCH_MAX_PAGE_SIZE } from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
|
||||||
|
const searchBoard = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||||
|
workOrdersApi: {
|
||||||
|
searchBoard: (...args: unknown[]) => searchBoard(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
|
||||||
|
|
||||||
|
function pageResult(items: Pick<WorkOrderTableRow, "id">[], totalCount: number) {
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
scheduled: [],
|
||||||
|
unscheduled: [],
|
||||||
|
totalCount,
|
||||||
|
returnedCount: items.length,
|
||||||
|
page: 0,
|
||||||
|
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fetchUnassignedBoardRows", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchBoard.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pages until totalCount is exhausted", async () => {
|
||||||
|
const first = Array.from({ length: BOARD_SEARCH_MAX_PAGE_SIZE }, (_, index) => ({
|
||||||
|
id: `a-${index}`,
|
||||||
|
}));
|
||||||
|
const second = [{ id: "b-0" }];
|
||||||
|
searchBoard
|
||||||
|
.mockResolvedValueOnce(pageResult(first, 101))
|
||||||
|
.mockResolvedValueOnce(pageResult(second, 101));
|
||||||
|
|
||||||
|
const rows = await fetchUnassignedBoardRows();
|
||||||
|
|
||||||
|
expect(searchBoard).toHaveBeenCalledTimes(2);
|
||||||
|
expect(searchBoard.mock.calls[0]?.[0]).toMatchObject({
|
||||||
|
page: 0,
|
||||||
|
dispatchers: ["__unassigned__"],
|
||||||
|
datePreset: "Custom",
|
||||||
|
});
|
||||||
|
expect(searchBoard.mock.calls[1]?.[0]).toMatchObject({ page: 1 });
|
||||||
|
expect(rows).toHaveLength(101);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -174,4 +174,64 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
|
||||||
expect(mapWorkOrderTableRow({ id: 1, flagColor: "" }).flagColor).toBeNull();
|
expect(mapWorkOrderTableRow({ id: 1, flagColor: "" }).flagColor).toBeNull();
|
||||||
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
|
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps uplift summary and pending flag from API payload", () => {
|
||||||
|
const row = mapWorkOrderTableRow({
|
||||||
|
id: 303,
|
||||||
|
upliftSummary: {
|
||||||
|
hasUplift: true,
|
||||||
|
pendingCount: 2,
|
||||||
|
primaryStatus: "pending",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(row.upliftSummary).toEqual({
|
||||||
|
hasUplift: true,
|
||||||
|
pendingCount: 2,
|
||||||
|
primaryStatus: "pending",
|
||||||
|
});
|
||||||
|
expect(row.hasPendingUplift).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps board pendingUpliftCount into hasPendingUplift (SH-188 contract)", () => {
|
||||||
|
const pending = mapWorkOrderTableRow({
|
||||||
|
id: 305,
|
||||||
|
pendingUpliftCount: 1,
|
||||||
|
});
|
||||||
|
expect(pending.hasPendingUplift).toBe(true);
|
||||||
|
expect(pending.upliftSummary).toEqual({ hasUplift: true, pendingCount: 1 });
|
||||||
|
|
||||||
|
const clear = mapWorkOrderTableRow({
|
||||||
|
id: 306,
|
||||||
|
PendingUpliftCount: 0,
|
||||||
|
});
|
||||||
|
expect(clear.hasPendingUplift).toBe(false);
|
||||||
|
expect(clear.upliftSummary).toEqual({ hasUplift: false, pendingCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not copy work-order status into uplift primaryStatus on flat board rows", () => {
|
||||||
|
const row = mapWorkOrderTableRow({
|
||||||
|
id: 308,
|
||||||
|
pendingUpliftCount: 1,
|
||||||
|
status: "In Progress",
|
||||||
|
Status: "In Progress",
|
||||||
|
});
|
||||||
|
expect(row.upliftSummary?.primaryStatus).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves hasPendingUplift undefined when board count is absent", () => {
|
||||||
|
const row = mapWorkOrderTableRow({ id: 307 });
|
||||||
|
expect(row.hasPendingUplift).toBeUndefined();
|
||||||
|
expect(row.upliftSummary).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps hasPendingUplift from top-level API flag", () => {
|
||||||
|
const row = mapWorkOrderTableRow({
|
||||||
|
id: 304,
|
||||||
|
hasPendingUplift: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(row.hasPendingUplift).toBe(true);
|
||||||
|
expect(row.upliftSummary).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import {
|
||||||
buildBoardSearchParamsRecord,
|
buildBoardSearchParamsRecord,
|
||||||
toBoardQueryParams,
|
toBoardQueryParams,
|
||||||
toBoardSearchQueryParams,
|
toBoardSearchQueryParams,
|
||||||
|
toUnassignedQueueQueryParams,
|
||||||
|
UNASSIGNED_QUEUE_DATE_FROM,
|
||||||
|
UNASSIGNED_QUEUE_DATE_TO,
|
||||||
} from "@/domain/work-orders/utils/board-query-params";
|
} from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
|
||||||
describe("toBoardQueryParams", () => {
|
describe("toBoardQueryParams", () => {
|
||||||
|
|
@ -221,3 +224,21 @@ describe("buildBoardSearchParamsRecord", () => {
|
||||||
expect(Object.keys(record)).not.toContain("types[]");
|
expect(Object.keys(record)).not.toContain("types[]");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("toUnassignedQueueQueryParams", () => {
|
||||||
|
it("requests unassigned dispatchers with a Custom range, not the weekly board window", () => {
|
||||||
|
const params = toUnassignedQueueQueryParams(2);
|
||||||
|
const record = buildBoardSearchParamsRecord(params);
|
||||||
|
|
||||||
|
expect(params.page).toBe(2);
|
||||||
|
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
|
||||||
|
expect(params.dispatchers).toEqual(["__unassigned__"]);
|
||||||
|
expect(params.statuses).not.toContain(7);
|
||||||
|
expect(params.statuses).not.toContain(9);
|
||||||
|
expect(params.datePreset).toBe("Custom");
|
||||||
|
expect(params.dateFrom).toBe(UNASSIGNED_QUEUE_DATE_FROM);
|
||||||
|
expect(params.dateTo).toBe(UNASSIGNED_QUEUE_DATE_TO);
|
||||||
|
expect(record).not.toHaveProperty("weekStart");
|
||||||
|
expect(record).not.toHaveProperty("weekEnd");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
||||||
import {
|
import {
|
||||||
buildDayGroups,
|
buildDayGroups,
|
||||||
|
composeBoardSections,
|
||||||
computeIsPastDue,
|
computeIsPastDue,
|
||||||
enrichTableRow,
|
enrichTableRow,
|
||||||
|
filterByBarAndSearch,
|
||||||
|
isUnassignedBoardRow,
|
||||||
matchesAdvancedFilters,
|
matchesAdvancedFilters,
|
||||||
|
mergeWeeklyAndUnassignedQueue,
|
||||||
|
partitionBoardRows,
|
||||||
splitScheduledUnscheduled,
|
splitScheduledUnscheduled,
|
||||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
|
@ -67,6 +73,18 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bar(
|
||||||
|
weekMonday: string,
|
||||||
|
overrides: Partial<WorkOrderBarFilters> = {},
|
||||||
|
): WorkOrderBarFilters {
|
||||||
|
return {
|
||||||
|
weekMonday,
|
||||||
|
dispatcherIds: new Set(),
|
||||||
|
type: "All",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("deriveDayFields", () => {
|
describe("deriveDayFields", () => {
|
||||||
it("derives MM/DD group and weekday label", () => {
|
it("derives MM/DD group and weekday label", () => {
|
||||||
expect(deriveDayFields("2026-06-23")).toEqual({
|
expect(deriveDayFields("2026-06-23")).toEqual({
|
||||||
|
|
@ -76,6 +94,162 @@ describe("deriveDayFields", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("filterByBarAndSearch", () => {
|
||||||
|
const weekMonday = "2026-06-01";
|
||||||
|
|
||||||
|
it("keeps undated rows without targetWeek for any active week", () => {
|
||||||
|
const undated = makeRow({ id: "us1", scheduledOn: "", targetWeek: "" });
|
||||||
|
|
||||||
|
const ids = filterByBarAndSearch([undated], bar(weekMonday), "").map((row) => row.id);
|
||||||
|
expect(ids).toEqual(["us1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes undated rows whose targetWeek is outside the active week", () => {
|
||||||
|
const undatedOtherTarget = makeRow({
|
||||||
|
id: "tw1",
|
||||||
|
scheduledOn: "",
|
||||||
|
targetWeek: "2026-07-06",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = filterByBarAndSearch([undatedOtherTarget], bar(weekMonday), "").map(
|
||||||
|
(row) => row.id,
|
||||||
|
);
|
||||||
|
expect(ids).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes dated rows outside the active week", () => {
|
||||||
|
const inWeek = makeRow({
|
||||||
|
id: "in",
|
||||||
|
scheduledOn: "2026-06-03",
|
||||||
|
...deriveDayFields("2026-06-03"),
|
||||||
|
});
|
||||||
|
const outWeek = makeRow({
|
||||||
|
id: "out",
|
||||||
|
scheduledOn: "2026-06-15",
|
||||||
|
...deriveDayFields("2026-06-15"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = filterByBarAndSearch([inWeek, outWeek], bar(weekMonday), "").map((row) => row.id);
|
||||||
|
expect(ids).toEqual(["in"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isUnassignedBoardRow", () => {
|
||||||
|
it("treats empty dispatcher as unassigned unless terminal", () => {
|
||||||
|
expect(isUnassignedBoardRow(makeRow({ dispatcherId: "" }))).toBe(true);
|
||||||
|
expect(isUnassignedBoardRow(makeRow({ dispatcherId: "u1" }))).toBe(false);
|
||||||
|
expect(isUnassignedBoardRow(makeRow({ dispatcherId: "", status: "Completed" }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partitionBoardRows", () => {
|
||||||
|
it("pins unassigned rows first and keeps assigned undated rows in Unscheduled", () => {
|
||||||
|
const datedUnassigned = makeRow({
|
||||||
|
id: "u-dated",
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
scheduledOn: "2026-06-02",
|
||||||
|
...deriveDayFields("2026-06-02"),
|
||||||
|
});
|
||||||
|
const undatedUnassigned = makeRow({
|
||||||
|
id: "us-undated",
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
scheduledOn: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
});
|
||||||
|
const undatedAssigned = makeRow({
|
||||||
|
id: "us-assigned",
|
||||||
|
dispatcherId: "sarah",
|
||||||
|
dispatcherName: "Sarah",
|
||||||
|
scheduledOn: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
});
|
||||||
|
const datedAssigned = makeRow({ id: "day-assigned", dispatcherId: "sarah" });
|
||||||
|
|
||||||
|
const result = partitionBoardRows([
|
||||||
|
datedUnassigned,
|
||||||
|
undatedUnassigned,
|
||||||
|
undatedAssigned,
|
||||||
|
datedAssigned,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.unassigned.map((row) => row.id)).toEqual(["u-dated", "us-undated"]);
|
||||||
|
expect(result.unscheduled.map((row) => row.id)).toEqual(["us-assigned"]);
|
||||||
|
expect(result.scheduled.map((row) => row.id)).toEqual(["day-assigned"]);
|
||||||
|
|
||||||
|
const allIds = [...result.unassigned, ...result.unscheduled, ...result.scheduled].map(
|
||||||
|
(row) => row.id,
|
||||||
|
);
|
||||||
|
expect(new Set(allIds).size).toBe(allIds.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeWeeklyAndUnassignedQueue", () => {
|
||||||
|
it("keeps the weekly row when the same id is also in the queue", () => {
|
||||||
|
const weekly = makeRow({ id: "dup", dispatcherName: "Weekly" });
|
||||||
|
const queue = makeRow({ id: "dup", dispatcherName: "Queue" });
|
||||||
|
const extra = makeRow({ id: "out", dispatcherId: "", dispatcherName: "" });
|
||||||
|
|
||||||
|
const merged = mergeWeeklyAndUnassignedQueue([weekly], [queue, extra]);
|
||||||
|
expect(merged.map((row) => row.id)).toEqual(["dup", "out"]);
|
||||||
|
expect(merged[0]?.dispatcherName).toBe("Weekly");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("composeBoardSections", () => {
|
||||||
|
it("pins a scheduled unassigned WO that is outside the selected week", () => {
|
||||||
|
const inWeekAssigned = makeRow({
|
||||||
|
id: "in-week",
|
||||||
|
dispatcherId: "sarah",
|
||||||
|
dispatcherName: "Sarah",
|
||||||
|
scheduledOn: "2026-06-03",
|
||||||
|
...deriveDayFields("2026-06-03"),
|
||||||
|
});
|
||||||
|
const outOfWeekUnassigned = makeRow({
|
||||||
|
id: "out-week",
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
scheduledOn: "2026-07-15",
|
||||||
|
...deriveDayFields("2026-07-15"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = composeBoardSections({
|
||||||
|
displayRows: [inWeekAssigned, outOfWeekUnassigned],
|
||||||
|
weekRows: [inWeekAssigned],
|
||||||
|
weeklyIds: new Set(["in-week"]),
|
||||||
|
queueIds: new Set(["out-week"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.unassigned.map((row) => row.id)).toEqual(["out-week"]);
|
||||||
|
expect(result.scheduled.map((row) => row.id)).toEqual(["in-week"]);
|
||||||
|
expect(result.unscheduled).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not place an assigned leftover queue row into the current week groups", () => {
|
||||||
|
const assignedOutOfWeek = makeRow({
|
||||||
|
id: "was-queue",
|
||||||
|
dispatcherId: "sarah",
|
||||||
|
dispatcherName: "Sarah",
|
||||||
|
scheduledOn: "2026-07-15",
|
||||||
|
...deriveDayFields("2026-07-15"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = composeBoardSections({
|
||||||
|
displayRows: [assignedOutOfWeek],
|
||||||
|
weekRows: [assignedOutOfWeek],
|
||||||
|
weeklyIds: new Set(),
|
||||||
|
queueIds: new Set(["was-queue"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.unassigned).toEqual([]);
|
||||||
|
expect(result.scheduled).toEqual([]);
|
||||||
|
expect(result.unscheduled).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("splitScheduledUnscheduled", () => {
|
describe("splitScheduledUnscheduled", () => {
|
||||||
it("splits rows without scheduledOn into unscheduled", () => {
|
it("splits rows without scheduledOn into unscheduled", () => {
|
||||||
const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });
|
const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });
|
||||||
|
|
|
||||||
159
src/test/domain/work-orders/utils/wo-closability.test.ts
Normal file
159
src/test/domain/work-orders/utils/wo-closability.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
import {
|
||||||
|
PENDING_UPLIFT_GAP,
|
||||||
|
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||||
|
canGenerateCompDoc,
|
||||||
|
formatClosabilityMissingTitle,
|
||||||
|
getClosabilityGaps,
|
||||||
|
hasPendingUplift,
|
||||||
|
isRealWoNumber,
|
||||||
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
|
||||||
|
function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityInput {
|
||||||
|
return {
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Alex",
|
||||||
|
company: "Acme HVAC",
|
||||||
|
tech: "Sam",
|
||||||
|
completedDate: "2026-07-01",
|
||||||
|
woNumber: "1001",
|
||||||
|
pm: "HVAC",
|
||||||
|
docStatus: "Yes",
|
||||||
|
mediaCount: 1,
|
||||||
|
type: "PM",
|
||||||
|
hasPendingUplift: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isRealWoNumber", () => {
|
||||||
|
it("rejects empty and SH-prefixed provisional numbers", () => {
|
||||||
|
expect(isRealWoNumber("")).toBe(false);
|
||||||
|
expect(isRealWoNumber("SH-9")).toBe(false);
|
||||||
|
expect(isRealWoNumber("sh123")).toBe(false);
|
||||||
|
expect(isRealWoNumber("98765")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasPendingUplift", () => {
|
||||||
|
it("is true when hasPendingUplift flag is set", () => {
|
||||||
|
expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when upliftSummary.pendingCount is positive", () => {
|
||||||
|
expect(
|
||||||
|
hasPendingUplift(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 2 } })),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when uplift is explicitly not pending", () => {
|
||||||
|
expect(hasPendingUplift(makeInput())).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getClosabilityGaps", () => {
|
||||||
|
it("returns empty when all gates are satisfied", () => {
|
||||||
|
expect(getClosabilityGaps(makeInput())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows company without technician (SH-183)", () => {
|
||||||
|
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
|
||||||
|
expect(gaps).toEqual([]);
|
||||||
|
expect(gaps).not.toContain("Technician");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Completed when company is missing even if technician is assigned", () => {
|
||||||
|
expect(getClosabilityGaps(makeInput({ company: "", tech: "Sam" }))).toEqual(["Company"]);
|
||||||
|
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists field gaps before uplift and document gaps", () => {
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps(
|
||||||
|
makeInput({
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
company: " ",
|
||||||
|
tech: " ",
|
||||||
|
completedDate: "",
|
||||||
|
woNumber: "SH-1",
|
||||||
|
pm: "",
|
||||||
|
hasPendingUplift: true,
|
||||||
|
docStatus: "No",
|
||||||
|
type: "PM",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"Dispatcher",
|
||||||
|
"Company",
|
||||||
|
"Completed Date",
|
||||||
|
"WO number",
|
||||||
|
"Service",
|
||||||
|
PENDING_UPLIFT_GAP,
|
||||||
|
"Completion document",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Completed when uplift is pending via summary", () => {
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 1 } })),
|
||||||
|
).toEqual([PENDING_UPLIFT_GAP]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Completed when hasPendingUplift is true", () => {
|
||||||
|
expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Completed when uplift status fields are absent (no pending evidence)", () => {
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a photo for Emergency / Reactive", () => {
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })),
|
||||||
|
).toEqual(["At least 1 photo"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatClosabilityMissingTitle", () => {
|
||||||
|
it("uses design Completed title when only uplift is missing", () => {
|
||||||
|
expect(formatClosabilityMissingTitle([PENDING_UPLIFT_GAP])).toBe(
|
||||||
|
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses design Completed title when pending uplift is among mixed gaps", () => {
|
||||||
|
expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe(
|
||||||
|
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins multiple non-uplift gaps with Missing prefix", () => {
|
||||||
|
expect(formatClosabilityMissingTitle(["Dispatcher", "Company"])).toBe(
|
||||||
|
"Missing: Dispatcher, Company",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canGenerateCompDoc", () => {
|
||||||
|
it("allows generation when no closability gaps", () => {
|
||||||
|
expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows generation when only the completion document is missing", () => {
|
||||||
|
expect(canGenerateCompDoc(makeInput({ docStatus: "No" }))).toEqual({ allowed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks generation with design reason when only pending uplift exists", () => {
|
||||||
|
expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({
|
||||||
|
allowed: false,
|
||||||
|
reason: UPLIFT_PENDING_COMPLETED_TITLE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
tmp/pr-descriptions/pr4-uplift-pending-close-gate.md
Normal file
39
tmp/pr-descriptions/pr4-uplift-pending-close-gate.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# PR4 — Pending uplift close gate (+ SH-114 closability union)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188).
|
||||||
|
|
||||||
|
- Unifies `wo-closability.ts` / `getClosabilityGaps` with hierarchy: **fields → uplift pending → completion document / photos**.
|
||||||
|
- Field gates: dispatcher, technician, completed date, real WO# (non-SH), service; then PM/Overdue completion doc; Emergency/Reactive ≥1 photo.
|
||||||
|
- Pending uplift via `hasPendingUplift` or `upliftSummary.pendingCount`.
|
||||||
|
- Completed tooltip uses design copy when the **only** gap is pending uplift.
|
||||||
|
- Maps optional `completedDate` on board rows for the Completed Date gate.
|
||||||
|
|
||||||
|
## Jira
|
||||||
|
|
||||||
|
- [SH-188](https://luby-us.atlassian.net/browse/SH-188) — pending uplift close gate
|
||||||
|
- Related: [SH-114](https://luby-us.atlassian.net/browse/SH-114) — closability field matrix (pulled into this branch so sister merges do not wipe gates)
|
||||||
|
|
||||||
|
## Comp Doc Generate
|
||||||
|
|
||||||
|
`CompDocDialog` is **not** mounted on `dev` yet. This PR exports `canGenerateCompDoc(row)` so the completion-doc slice can block generate/open with the same reason string. No UI wiring in this PR.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Full uplift CRUD (PR3)
|
||||||
|
- Advanced filter facets for uplift
|
||||||
|
- Scheduled-requires-date gate (design P1)
|
||||||
|
- COL_HELP tooltips (P1)
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [ ] Row with all fields ok + `hasPendingUplift: true` — Completed disabled; design tooltip about pending uplift.
|
||||||
|
- [ ] Row missing dispatcher / completed date — Completed disabled; `Missing: …` tooltip.
|
||||||
|
- [ ] Row fully closable — Completed selectable.
|
||||||
|
- [ ] `canGenerateCompDoc` blocked when gaps exist.
|
||||||
|
- [ ] Board API `completedDate` / `upliftSummary.pendingCount` map through to the gate.
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Merge **before** or instead of stacking raw SH-114 alone with an uplift-only closability overwrite.
|
||||||
Loading…
Add table
Reference in a new issue