mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 10:53:11 +00:00
fix(work-orders): implement SH-165 Unassigned pinned queue
Replace Unscheduled-focused changes with assignment-based board partition, accessible disclosure, duplicate prevention, and inline unassign support.
This commit is contained in:
parent
f4cfafd7a6
commit
93034f5da2
15 changed files with 396 additions and 305 deletions
|
|
@ -118,15 +118,6 @@ Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3.
|
|||
}
|
||||
```
|
||||
|
||||
#### Buckets
|
||||
|
||||
| Array | Inclusion rule |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `scheduled` | WOs with `scheduledDate` inside `weekStart`–`weekEnd` |
|
||||
| `unscheduled` | **All** WOs without `scheduledDate` (cross-week). Visible for every week request; `targetWeek` is not a week filter — Schedule-cell marker only |
|
||||
|
||||
The FE concatenates `unscheduled + scheduled` into `items` and renders `unscheduled` in the pinned Unscheduled section above day groups. Dated WOs without a dispatcher stay in `scheduled` / day groups (Assigned To highlight), not in `unscheduled`.
|
||||
|
||||
### `/board/search`
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
|||
/>
|
||||
</div>
|
||||
<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) => (
|
||||
<button
|
||||
key={String(d.id)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { Fragment, type ReactNode } from "react";
|
||||
import type { WorkOrderTableRow } 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 type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
import { WoTableSectionHeader } from "./wo-table-section-header";
|
||||
|
||||
|
|
@ -15,11 +18,30 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
|||
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
||||
}
|
||||
|
||||
const showUnassigned =
|
||||
tableData.unassigned.length > 0 || tableData.draftDayKey === UNASSIGNED_DAY_KEY;
|
||||
const showUnscheduled =
|
||||
tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
{showUnassigned && (
|
||||
<>
|
||||
<WoTableSectionHeader
|
||||
variant="unassigned"
|
||||
label="Unassigned"
|
||||
count={tableData.unassigned.length}
|
||||
collapsed={!tableData.unassignedOpen}
|
||||
onToggleCollapse={() => tableData.setUnassignedOpen((v) => !v)}
|
||||
/>
|
||||
{tableData.unassignedOpen && (
|
||||
<>
|
||||
{renderInlineRow(UNASSIGNED_DAY_KEY)}
|
||||
{tableData.unassigned.map((row) => renderRow(row))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showUnscheduled && (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,10 @@ import type {
|
|||
|
||||
import type { WorkOrderTableDraft } 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 { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
||||
|
|
@ -65,7 +68,7 @@ export function WoTableInlineRow({
|
|||
|
||||
onCancel,
|
||||
}: WoTableInlineRowProps) {
|
||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY;
|
||||
|
||||
const canSave =
|
||||
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
|
||||
|
|
|
|||
|
|
@ -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 { WoTableCollapsibleSectionHeader } from "./wo-table-collapsible-section-header";
|
||||
|
||||
type WoTableSectionHeaderProps = {
|
||||
variant: "unscheduled" | "day";
|
||||
variant: "unassigned" | "unscheduled" | "day";
|
||||
label: string;
|
||||
count: number;
|
||||
isToday?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onAddWo: () => void;
|
||||
onAddWo?: () => void;
|
||||
};
|
||||
|
||||
export function WoTableSectionHeader({
|
||||
|
|
@ -20,72 +21,30 @@ export function WoTableSectionHeader({
|
|||
onToggleCollapse,
|
||||
onAddWo,
|
||||
}: WoTableSectionHeaderProps) {
|
||||
const isUnscheduled = variant === "unscheduled";
|
||||
|
||||
if (isUnscheduled) {
|
||||
const toggleCollapse = () => {
|
||||
onToggleCollapse?.();
|
||||
};
|
||||
|
||||
if (variant === "unassigned") {
|
||||
return (
|
||||
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
|
||||
<td
|
||||
colSpan={WO_TABLE_COLS.length}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 14px",
|
||||
boxShadow: "inset 3px 0 0 var(--warning)",
|
||||
borderTop: "1px solid #FDE68A",
|
||||
borderBottom: "1px solid #FDE68A",
|
||||
}}
|
||||
>
|
||||
<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={toggleCollapse}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={14} className="shrink-0" style={{ color: "var(--warning)" }} />
|
||||
<span
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
color: "var(--warning)",
|
||||
}}
|
||||
>
|
||||
{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: "var(--warning)" }}>
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
<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>
|
||||
<WoTableCollapsibleSectionHeader
|
||||
tone="unassigned"
|
||||
icon={Users}
|
||||
label={label}
|
||||
count={count}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={() => onToggleCollapse?.()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "unscheduled") {
|
||||
return (
|
||||
<WoTableCollapsibleSectionHeader
|
||||
tone="unscheduled"
|
||||
icon={AlertTriangle}
|
||||
label={label}
|
||||
count={count}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={() => onToggleCollapse?.()}
|
||||
onAddWo={onAddWo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -139,15 +98,17 @@ export function WoTableSectionHeader({
|
|||
Today
|
||||
</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={onAddWo}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add WO
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/t
|
|||
import {
|
||||
buildDayGroups,
|
||||
filterByBarAndSearch,
|
||||
partitionBoardRows,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
||||
|
|
@ -38,10 +39,13 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
return displayRows;
|
||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||
|
||||
const { scheduled, unscheduled } = useMemo(
|
||||
() => (advActive ? splitScheduledUnscheduled(advResults) : splitScheduledUnscheduled(filtered)),
|
||||
[advActive, advResults, filtered],
|
||||
);
|
||||
const { unassigned, scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
const split = splitScheduledUnscheduled(advResults);
|
||||
return { unassigned: [], ...split };
|
||||
}
|
||||
return partitionBoardRows(filtered);
|
||||
}, [advActive, advResults, filtered]);
|
||||
|
||||
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
||||
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
||||
|
|
@ -49,7 +53,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
const searchActive = filters.search.trim() !== "";
|
||||
const visibleRows = advActive ? advResults : filtered;
|
||||
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 todayKey =
|
||||
|
|
@ -70,6 +79,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
refetch,
|
||||
advActive,
|
||||
advResults,
|
||||
unassigned,
|
||||
unscheduled,
|
||||
displayGroups,
|
||||
searchActive,
|
||||
|
|
@ -79,6 +89,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
draftDayKey: local.draftDayKey,
|
||||
draft: local.draft,
|
||||
setDraft: local.setDraft,
|
||||
unassignedOpen: local.unassignedOpen,
|
||||
setUnassignedOpen: local.setUnassignedOpen,
|
||||
unschedOpen: local.unschedOpen,
|
||||
setUnschedOpen: local.setUnschedOpen,
|
||||
selectedId: local.selectedId,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ export function useWorkOrdersTableLocalState({
|
|||
setDraft: ui.setDraft,
|
||||
startDayDraft: ui.startDayDraft,
|
||||
cancelDraft: ui.cancelDraft,
|
||||
unassignedOpen: ui.unassignedOpen,
|
||||
setUnassignedOpen: ui.setUnassignedOpen,
|
||||
unschedOpen: ui.unschedOpen,
|
||||
setUnschedOpen: ui.setUnschedOpen,
|
||||
selectedId: ui.selectedId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
EMPTY_TABLE_DRAFT,
|
||||
UNASSIGNED_DAY_KEY,
|
||||
UNSCHEDULED_DAY_KEY,
|
||||
type WorkOrderTableDraft,
|
||||
} 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 {
|
||||
if (key === UNSCHEDULED_DAY_KEY) {
|
||||
if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) {
|
||||
return "";
|
||||
}
|
||||
if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) {
|
||||
|
|
@ -31,10 +32,11 @@ function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?:
|
|||
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) {
|
||||
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
||||
const [unassignedOpen, setUnassignedOpen] = useState(true);
|
||||
const [unschedOpen, setUnschedOpen] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
||||
const [flashId, setFlashId] = useState<string | number | null>(null);
|
||||
|
|
@ -81,6 +83,8 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
|||
setDraft,
|
||||
startDayDraft,
|
||||
cancelDraft,
|
||||
unassignedOpen,
|
||||
setUnassignedOpen,
|
||||
unschedOpen,
|
||||
setUnschedOpen,
|
||||
selectedId,
|
||||
|
|
|
|||
|
|
@ -115,3 +115,4 @@ export type WorkOrderDayGroup = {
|
|||
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
||||
|
||||
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
|
||||
export const UNASSIGNED_DAY_KEY = "__unassigned__" as const;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export type {
|
|||
|
||||
export {
|
||||
EMPTY_TABLE_DRAFT,
|
||||
UNASSIGNED_DAY_KEY,
|
||||
UNSCHEDULED_DAY_KEY,
|
||||
WORK_ORDERS_TABLE_PAGE_SIZE,
|
||||
} from "./work-order-table-row";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates";
|
||||
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||
|
||||
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
|
||||
|
|
@ -90,16 +91,47 @@ export function filterByBarAndSearch(
|
|||
}
|
||||
}
|
||||
|
||||
// Design weekScoped: undated (!scheduledOn) stay visible every week; targetWeek
|
||||
// is a Schedule-cell marker only, never a week-inclusion predicate.
|
||||
if (row.scheduledOn && (row.scheduledOn < weekFrom || row.scheduledOn > weekTo)) {
|
||||
return false;
|
||||
if (row.scheduledOn) {
|
||||
if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
|
||||
return false;
|
||||
}
|
||||
} else if (row.targetWeek) {
|
||||
if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
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 splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
|
||||
scheduled: WorkOrderTableRow[];
|
||||
unscheduled: WorkOrderTableRow[];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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";
|
||||
|
|
@ -62,6 +63,7 @@ function baseTableData(
|
|||
refetch: vi.fn(),
|
||||
advActive: false,
|
||||
advResults: [],
|
||||
unassigned: [],
|
||||
unscheduled: [],
|
||||
displayGroups: [],
|
||||
searchActive: false,
|
||||
|
|
@ -71,6 +73,8 @@ function baseTableData(
|
|||
draftDayKey: null,
|
||||
draft: null,
|
||||
setDraft: vi.fn(),
|
||||
unassignedOpen: true,
|
||||
setUnassignedOpen: vi.fn(),
|
||||
unschedOpen: true,
|
||||
setUnschedOpen: vi.fn(),
|
||||
selectedId: null,
|
||||
|
|
@ -95,28 +99,43 @@ function baseTableData(
|
|||
} as ReturnTypeOfUseWorkOrdersTableData;
|
||||
}
|
||||
|
||||
describe("WoTableBody Unscheduled vs day groups", () => {
|
||||
it("renders Unscheduled above day groups and keeps dated unassigned under the day header", () => {
|
||||
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 undated = makeRow({
|
||||
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({
|
||||
unscheduled: [undated],
|
||||
unassigned: [datedUnassigned, undatedUnassigned],
|
||||
unscheduled: [undatedAssigned],
|
||||
displayGroups: [
|
||||
{
|
||||
key: "06/02",
|
||||
label: "TUE · 06/02",
|
||||
date: "2026-06-02",
|
||||
items: [datedUnassigned],
|
||||
items: [datedAssigned],
|
||||
},
|
||||
],
|
||||
unschedOpen: true,
|
||||
});
|
||||
|
||||
render(
|
||||
|
|
@ -133,21 +152,54 @@ describe("WoTableBody Unscheduled vs day groups", () => {
|
|||
</table>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Unscheduled" })).toBeInTheDocument();
|
||||
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
|
||||
|
||||
const unscheduledHeader = screen.getByRole("button", { name: "Unscheduled" });
|
||||
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");
|
||||
const undatedRow = screen.getByTestId("row-us1");
|
||||
const datedRow = screen.getByTestId("row-u-dated");
|
||||
|
||||
expect(
|
||||
unscheduledHeader.compareDocumentPosition(undatedRow) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
dayLabel.compareDocumentPosition(datedRow) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTestId("row-u-dated")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId(/row-/)).toHaveLength(2);
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import {
|
||||
filterByBarAndSearch,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||
|
||||
/**
|
||||
* Design oracle — mirrors shoc-desing/src/pages/WorkOrders.tsx:
|
||||
* - weekScoped (~L9734): !scheduledOn || (scheduledOn >= wStart && scheduledOn <= wEnd)
|
||||
* - split (~L9762): unscheduled = !scheduledOn; dated-but-unassigned stay in day groups
|
||||
*/
|
||||
|
||||
type DesignRow = Pick<WorkOrderTableRow, "id" | "scheduledOn" | "dispatcherId" | "targetWeek">;
|
||||
|
||||
function designWeekScoped(rows: DesignRow[], weekStart: string, weekEnd: string): DesignRow[] {
|
||||
return rows.filter(
|
||||
(row) => !row.scheduledOn || (row.scheduledOn >= weekStart && row.scheduledOn <= weekEnd),
|
||||
);
|
||||
}
|
||||
|
||||
function designSplit(rows: DesignRow[]): {
|
||||
scheduled: DesignRow[];
|
||||
unscheduled: DesignRow[];
|
||||
} {
|
||||
const scheduled: DesignRow[] = [];
|
||||
const unscheduled: DesignRow[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.scheduledOn) {
|
||||
unscheduled.push(row);
|
||||
} else {
|
||||
scheduled.push(row);
|
||||
}
|
||||
}
|
||||
return { scheduled, unscheduled };
|
||||
}
|
||||
|
||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: "1",
|
||||
woNumber: "24991",
|
||||
site: "CLV2",
|
||||
type: "PM",
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alice",
|
||||
scheduledOn: "2026-06-02",
|
||||
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,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "",
|
||||
priority: "",
|
||||
vendorId: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function idsOf(rows: { id: string | number }[]): string[] {
|
||||
return rows.map((row) => String(row.id)).sort();
|
||||
}
|
||||
|
||||
describe("board Unscheduled design parity", () => {
|
||||
const weekMonday = "2026-06-01";
|
||||
const [weekStart, weekEnd] = weekRange(weekMonday);
|
||||
|
||||
const fixtures: WorkOrderTableRow[] = [
|
||||
makeRow({
|
||||
id: "u-dated",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
scheduledOn: "2026-06-02",
|
||||
...deriveDayFields("2026-06-02"),
|
||||
}),
|
||||
makeRow({
|
||||
id: "us-undated",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
scheduledOn: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
}),
|
||||
makeRow({
|
||||
id: "ua-assigned-undated",
|
||||
dispatcherId: "sarah",
|
||||
dispatcherName: "Sarah",
|
||||
scheduledOn: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
}),
|
||||
makeRow({
|
||||
id: "tw-other-week",
|
||||
dispatcherId: "sarah",
|
||||
dispatcherName: "Sarah",
|
||||
scheduledOn: "",
|
||||
targetWeek: "2026-07-06",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
}),
|
||||
makeRow({
|
||||
id: "dated-other-week",
|
||||
scheduledOn: "2026-06-15",
|
||||
...deriveDayFields("2026-06-15"),
|
||||
}),
|
||||
];
|
||||
|
||||
const bar: WorkOrderBarFilters = {
|
||||
weekMonday,
|
||||
dispatcherIds: new Set(),
|
||||
type: "All",
|
||||
};
|
||||
|
||||
it("matches design weekScoped ID set via filterByBarAndSearch", () => {
|
||||
const designIds = idsOf(designWeekScoped(fixtures, weekStart, weekEnd));
|
||||
const feIds = idsOf(filterByBarAndSearch(fixtures, bar, ""));
|
||||
expect(feIds).toEqual(designIds);
|
||||
expect(feIds).toContain("tw-other-week");
|
||||
expect(feIds).not.toContain("dated-other-week");
|
||||
});
|
||||
|
||||
it("matches design split: dated unassigned in scheduled, undated in Unscheduled", () => {
|
||||
const scoped = filterByBarAndSearch(fixtures, bar, "");
|
||||
const feSplit = splitScheduledUnscheduled(scoped);
|
||||
const designSplitResult = designSplit(designWeekScoped(fixtures, weekStart, weekEnd));
|
||||
|
||||
expect(idsOf(feSplit.unscheduled)).toEqual(idsOf(designSplitResult.unscheduled));
|
||||
expect(idsOf(feSplit.scheduled)).toEqual(idsOf(designSplitResult.scheduled));
|
||||
|
||||
expect(idsOf(feSplit.scheduled)).toContain("u-dated");
|
||||
expect(idsOf(feSplit.unscheduled)).not.toContain("u-dated");
|
||||
expect(idsOf(feSplit.unscheduled)).toContain("tw-other-week");
|
||||
expect(idsOf(feSplit.unscheduled)).toEqual(
|
||||
expect.arrayContaining(["us-undated", "ua-assigned-undated", "tw-other-week"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,9 @@ import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-
|
|||
import {
|
||||
buildDayGroups,
|
||||
filterByBarAndSearch,
|
||||
isUnassignedBoardRow,
|
||||
matchesAdvancedFilters,
|
||||
partitionBoardRows,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
|
|
@ -83,18 +85,24 @@ describe("deriveDayFields", () => {
|
|||
describe("filterByBarAndSearch", () => {
|
||||
const weekMonday = "2026-06-01";
|
||||
|
||||
it("keeps undated rows for any week, including targetWeek outside the active week", () => {
|
||||
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([undated, undatedOtherTarget], bar(weekMonday), "").map(
|
||||
const ids = filterByBarAndSearch([undatedOtherTarget], bar(weekMonday), "").map(
|
||||
(row) => row.id,
|
||||
);
|
||||
expect(ids).toEqual(["us1", "tw1"]);
|
||||
expect(ids).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes dated rows outside the active week", () => {
|
||||
|
|
@ -114,6 +122,59 @@ describe("filterByBarAndSearch", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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("splitScheduledUnscheduled", () => {
|
||||
it("splits rows without scheduledOn into unscheduled", () => {
|
||||
const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });
|
||||
|
|
@ -123,23 +184,6 @@ describe("splitScheduledUnscheduled", () => {
|
|||
expect(result.unscheduled).toHaveLength(1);
|
||||
expect(result.unscheduled[0].id).toBe("b");
|
||||
});
|
||||
|
||||
it("keeps dated unassigned in scheduled and undated in unscheduled without duplicate ids", () => {
|
||||
const datedUnassigned = makeRow({
|
||||
id: "u-dated",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
scheduledOn: "2026-06-02",
|
||||
...deriveDayFields("2026-06-02"),
|
||||
});
|
||||
const undated = makeRow({ id: "us-undated", scheduledOn: "", dispatcherId: "" });
|
||||
const result = splitScheduledUnscheduled([datedUnassigned, undated]);
|
||||
|
||||
expect(result.scheduled.map((row) => row.id)).toEqual(["u-dated"]);
|
||||
expect(result.unscheduled.map((row) => row.id)).toEqual(["us-undated"]);
|
||||
const allIds = [...result.scheduled, ...result.unscheduled].map((row) => row.id);
|
||||
expect(new Set(allIds).size).toBe(allIds.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDayGroups", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue