diff --git a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts
index 283b4ebd..7e7a7a78 100644
--- a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts
+++ b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts
@@ -17,6 +17,8 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
dispatcherId: draft.dispatcherId,
dispatcherName: "",
scheduledOn: draft.scheduledOn,
+ scheduleWeekOnly: draft.scheduleWeekOnly,
+ vendorId: draft.vendorId,
completedDate: "",
dueDate: draft.dueDate,
pm: draft.pm,
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
index 8f6482ea..cde640c8 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
@@ -13,6 +13,10 @@ import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
+import {
+ CREATION_INLINE_STATUSES,
+ withAutoScheduleStatus,
+} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
@@ -84,30 +88,36 @@ function InlineScheduleOnCell({
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
onSave={(date) =>
- onPatch({
- scheduledOn: date,
- scheduleWeekOnly: false,
- targetWeek: "",
- isAddOnOverride: false,
- })
+ onPatch(
+ withAutoScheduleStatus(draft, {
+ scheduledOn: date,
+ scheduleWeekOnly: false,
+ targetWeek: "",
+ isAddOnOverride: false,
+ }),
+ )
}
onSaveWeek={(week) =>
- onPatch({
- scheduledOn: "",
- targetWeek: week,
- scheduleWeekOnly: true,
- isAddOnOverride: false,
- })
+ onPatch(
+ withAutoScheduleStatus(draft, {
+ scheduledOn: "",
+ targetWeek: week,
+ scheduleWeekOnly: true,
+ isAddOnOverride: false,
+ }),
+ )
}
onClear={
hasSchedule
? () =>
- onPatch({
- scheduledOn: "",
- targetWeek: "",
- scheduleWeekOnly: false,
- isAddOnOverride: false,
- })
+ onPatch(
+ withAutoScheduleStatus(draft, {
+ scheduledOn: "",
+ targetWeek: "",
+ scheduleWeekOnly: false,
+ isAddOnOverride: false,
+ }),
+ )
: undefined
}
/>
@@ -215,6 +225,7 @@ export function WoTableInlineRowCells({
|
onPatch({ status })}
onOpenSlideOver={() => {}}
onRequestComplete={onRequestComplete}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx
index ed818025..283d732c 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx
@@ -84,6 +84,9 @@ export function WoTableRowCells({
carriedOver={row.carriedOver}
isAddOn={row.isAddOn}
originalDate={row.originalDate}
+ originalWeek={row.originalWeek}
+ scheduledOn={row.scheduledOn}
+ status={row.status}
q={search}
currentId={row.id}
allOrders={allRows}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx
new file mode 100644
index 00000000..cfd2dcf5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx
@@ -0,0 +1,40 @@
+import { GripVertical } from "lucide-react";
+
+type WoTableRowReorderHandleProps = {
+ disabled: boolean;
+ isDragging: boolean;
+ woNumber: string;
+ onArrowReorder: (delta: -1 | 1) => void;
+};
+
+export function WoTableRowReorderHandle({
+ disabled,
+ isDragging,
+ woNumber,
+ onArrowReorder,
+}: WoTableRowReorderHandleProps) {
+ return (
+ event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (disabled || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ onArrowReorder(event.key === "ArrowUp" ? -1 : 1);
+ }}
+ >
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx
index e9ab8450..459d775a 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx
@@ -1,4 +1,3 @@
-import { GripVertical } from "lucide-react";
import type {
LocationOption,
VendorDropdownItem,
@@ -7,6 +6,7 @@ import type {
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderTablePatchOptions } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
+import { neighborIdForReorder } from "@/domain/work-orders/utils/board-row-order-storage";
import {
canApplyWorkOrderTablePatch,
canMutateWorkOrderFlag,
@@ -16,6 +16,7 @@ import {
import { FlagCell } from "./cells/flag-cell";
import { WoTableRowActions } from "./wo-table-row-actions";
import { WoTableRowCells } from "./wo-table-row-cells";
+import { WoTableRowReorderHandle } from "./wo-table-row-reorder-handle";
import { WoTableRowServiceCells } from "./wo-table-row-service-cells";
import {
MUTATION_CELL_STYLE,
@@ -155,15 +156,21 @@ export function WoTableRow({
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
onClick={(e) => e.stopPropagation()}
>
- {
+ const overId = neighborIdForReorder(
+ allRows,
+ row,
+ delta,
+ (item) => !isWorkOrderFullyLocked(item.status),
+ );
+ if (overId == null) return;
+ handlers.onReorder(row.id, overId);
}}
- >
-
-
+ />
|
e.stopPropagation()}>
onPatch(clearSchedulePatch()) : undefined}
+ onClear={
+ hasWizardSchedule(draft)
+ ? () => onPatch(withAutoScheduleStatus(draft, clearSchedulePatch()))
+ : undefined
+ }
>
Schedule On
@@ -158,34 +163,40 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
- onPatch({
- scheduledOn: iso,
- scheduleWeekOnly: false,
- scheduleEnd: "",
- schedMode: "date",
- targetWeek: "",
- isAddOnOverride: false,
- })
+ onPatch(
+ withAutoScheduleStatus(draft, {
+ scheduledOn: iso,
+ scheduleWeekOnly: false,
+ scheduleEnd: "",
+ schedMode: "date",
+ targetWeek: "",
+ isAddOnOverride: false,
+ }),
+ )
}
onChangeWeek={(w) =>
- onPatch({
- scheduledOn: w,
- scheduleWeekOnly: Boolean(w),
- scheduleEnd: "",
- schedMode: "week",
- targetWeek: w,
- isAddOnOverride: false,
- })
+ onPatch(
+ withAutoScheduleStatus(draft, {
+ scheduledOn: w,
+ scheduleWeekOnly: Boolean(w),
+ scheduleEnd: "",
+ schedMode: "week",
+ targetWeek: w,
+ isAddOnOverride: false,
+ }),
+ )
}
onChangeRange={(s, e) =>
- onPatch({
- scheduledOn: s,
- scheduleEnd: e,
- schedMode: "range",
- scheduleWeekOnly: false,
- targetWeek: "",
- isAddOnOverride: false,
- })
+ onPatch(
+ withAutoScheduleStatus(draft, {
+ scheduledOn: s,
+ scheduleEnd: e,
+ schedMode: "range",
+ scheduleWeekOnly: false,
+ targetWeek: "",
+ isAddOnOverride: false,
+ }),
+ )
}
/>
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
index f009b9d7..1fbd6142 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
@@ -1,9 +1,7 @@
-import { useState } from "react";
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
- ALL_WIZARD_STATUSES,
TIME_SLOTS,
WIZARD_STATUS_DOTS,
type WizardWOStatus,
@@ -15,12 +13,15 @@ import {
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import {
- formatClosabilityMissingTitle,
getClosabilityGaps,
wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
+import {
+ CREATION_INLINE_STATUSES,
+ getStatusOptionState,
+ withAutoScheduleStatus,
+} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
-import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
@@ -173,15 +174,15 @@ function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFields
);
}
-function buildWizardStatusOptions(closabilityGaps: string[]) {
- return ALL_WIZARD_STATUSES.map((status) => {
- const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
+function buildWizardStatusOptions(draft: WorkOrderWizardDraft, closabilityGaps: string[]) {
+ return CREATION_INLINE_STATUSES.map((status) => {
+ const option = getStatusOptionState(draft, status, closabilityGaps);
return {
key: status,
label: status,
left: ,
- disabled: isCompletedBlocked,
- title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
+ disabled: option.disabled,
+ title: option.title,
};
});
}
@@ -192,7 +193,6 @@ export function WizardStepVendorTime({
onUpdate,
onPatch,
}: WizardStepVendorTimeProps) {
- const [confirmComplete, setConfirmComplete] = useState(false);
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
@@ -272,27 +272,16 @@ export function WizardStepVendorTime({
placeholder="Select status…"
search={false}
icon={}
- options={buildWizardStatusOptions(closabilityGaps)}
+ options={buildWizardStatusOptions(draft, closabilityGaps)}
onPick={(status) => {
- if (status === "Completed" && closabilityGaps.length > 0) return;
- if (status === "Completed" && draft.status !== "Completed") {
- setConfirmComplete(true);
+ const option = getStatusOptionState(draft, status as WizardWOStatus, closabilityGaps);
+ if (option.disabled) {
return;
}
- onUpdate("status", status as WizardWOStatus);
+ onPatch(withAutoScheduleStatus(draft, { status: status as WizardWOStatus }));
}}
/>
- setConfirmComplete(false)}
- onKeepOpen={() => setConfirmComplete(false)}
- onConfirm={() => {
- onUpdate("status", "Completed");
- setConfirmComplete(false);
- }}
- />
);
}
diff --git a/src/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id.ts b/src/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id.ts
new file mode 100644
index 00000000..b6b2032f
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id.ts
@@ -0,0 +1,6 @@
+import { getAuthUser } from "@/lib/auth/auth-storage";
+
+export function resolveBoardRowOrderOwnerId(): string {
+ const user = getAuthUser();
+ return user?.id == null ? "" : String(user.id);
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts
index f9847220..2163bcfb 100644
--- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts
@@ -1,25 +1,13 @@
import { useMemo } from "react";
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
+import { resolveBoardRowOrderOwnerId } from "@/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id";
import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page";
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
-import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
+import { useWorkOrdersTableDayView } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-day-view";
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
-import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
-import {
- pickBoardPager,
- pageSelectedDayGroups,
- type DayPageSize,
-} from "@/domain/work-orders/utils/day-pagination";
-import {
- buildSelectedDayGroup,
- isDayScopedSearchEmpty,
-} from "@/domain/work-orders/utils/week-day-nav-utils";
-import {
- filterByBarAndSearch,
- splitScheduledUnscheduled,
-} from "@/domain/work-orders/utils/table-filter-utils";
+import { pickBoardPager, type DayPageSize } from "@/domain/work-orders/utils/day-pagination";
+import { filterByBarAndSearch } from "@/domain/work-orders/utils/table-filter-utils";
export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
@@ -33,12 +21,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
useWorkOrdersBoardSource(filters);
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
+ const ownerId = resolveBoardRowOrderOwnerId();
const local = useWorkOrdersTableLocalState({
items: weeklyItems,
weekMonday: filters.weekMonday,
advActive,
isFetching,
+ ownerId,
});
const { displayRows, ...localUi } = local;
@@ -54,57 +44,22 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
return displayRows;
}, [displayRows, filters.barFilters, filters.search, advActive]);
- const { scheduled, unscheduled } = useMemo(() => {
- if (advActive) {
- return splitScheduledUnscheduled(advResults);
- }
- return splitScheduledUnscheduled(filtered);
- }, [advActive, advResults, filtered]);
-
- const dayGroups = useMemo(
- () =>
- advActive
- ? []
- : [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
- [advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
- );
- const pagination = useWorkOrdersDayPagination({
- weekMonday: filters.weekMonday,
- selectedDayIso: filters.selectedDayIso,
+ const dayView = useWorkOrdersTableDayView({
+ filters,
advActive,
- dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
+ filtered,
+ advResults,
onRevealed: local.triggerFlash,
});
- const pagedDay = useMemo(
- () => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
- [dayGroups, pagination.page, pagination.pageSize],
- );
-
- const searchActive = filters.search.trim() !== "";
- const displayGroups = pagedDay.groups;
- const dayTotalCount = pagedDay.totalCount;
- const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
- const noSearchResults =
- !advActive &&
- isDayScopedSearchEmpty({
- searchActive,
- unassignedCount: 0,
- dayItemCount: dayTotalCount,
- unassignedQueueReady: true,
- });
- const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
-
- const todayKey =
- filters.weekMonday === currentWeekMonday()
- ? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
- : null;
const selectedRow = useMemo(
() => displayRows.find((row) => String(row.id) === String(local.selectedId)) ?? null,
[displayRows, local.selectedId],
);
- const scopeTotalCount = advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount;
+ const scopeTotalCount = advActive
+ ? (data?.totalCount ?? advResults.length)
+ : dayView.dayTotalCount;
useSyncOutOfRangeSearchPage({
enabled: advActive,
ready: !isLoading,
@@ -113,11 +68,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
totalCount: scopeTotalCount,
setPage: filters.setPage,
});
- const listTotalCount = advActive ? advResults.length : dayVisibleCount;
+ const listTotalCount = advActive ? advResults.length : dayView.dayVisibleCount;
const pager = pickBoardPager({
advActive,
isLoading,
- noSearchResults,
+ noSearchResults: dayView.noSearchResults,
advResultCount: advResults.length,
search: {
page: filters.page,
@@ -125,11 +80,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
totalCount: scopeTotalCount,
},
day: {
- pageSize: pagination.pageSize,
- pageIndex: pagedDay.pageIndex,
- pageCount: pagedDay.pageCount,
- itemCount: dayTotalCount,
- footerLabel: displayGroups[0]?.label ?? "",
+ pageSize: dayView.pagination.pageSize,
+ pageIndex: dayView.pagedDay.pageIndex,
+ pageCount: dayView.pagedDay.pageCount,
+ itemCount: dayView.dayTotalCount,
+ footerLabel: dayView.displayGroups[0]?.label ?? "",
},
});
@@ -145,12 +100,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
unassignedTotalCount: 0,
unassignedError: null,
unassignedLoading: false,
- unscheduled,
- displayGroups,
- searchActive,
- noSearchResults,
- isEmptyWeek,
- todayKey,
+ unscheduled: dayView.unscheduled,
+ displayGroups: dayView.displayGroups,
+ searchActive: dayView.searchActive,
+ noSearchResults: dayView.noSearchResults,
+ isEmptyWeek: dayView.isEmptyWeek,
+ todayKey: dayView.todayKey,
...localUi,
selectedRow,
scopeTotalCount,
@@ -167,14 +122,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
filters.setPageSize(next);
return;
}
- pagination.setPageSize(next);
+ dayView.pagination.setPageSize(next);
},
goToPrevDayPage: advActive
? () => filters.setPage(Math.max(0, pager.pageIndex - 1))
- : pagination.goToPrevPage,
+ : dayView.pagination.goToPrevPage,
goToNextDayPage: advActive
? () => filters.setPage(Math.min(pager.pageIndex + 1, Math.max(0, pager.pageCount - 1)))
- : pagination.goToNextPage,
- revealScheduledRow: pagination.revealRow,
+ : dayView.pagination.goToNextPage,
+ revealScheduledRow: dayView.pagination.revealRow,
};
}
diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts
new file mode 100644
index 00000000..3f15b3cb
--- /dev/null
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts
@@ -0,0 +1,90 @@
+import { useMemo } from "react";
+import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
+import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
+import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
+import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination";
+import {
+ buildSelectedDayGroup,
+ isDayScopedSearchEmpty,
+} from "@/domain/work-orders/utils/week-day-nav-utils";
+import { splitScheduledUnscheduled } from "@/domain/work-orders/utils/table-filter-utils";
+
+type FiltersReturn = ReturnType;
+
+export type UseWorkOrdersTableDayViewParams = {
+ filters: FiltersReturn;
+ advActive: boolean;
+ filtered: WorkOrderTableRow[];
+ advResults: WorkOrderTableRow[];
+ onRevealed: (id: string | number) => void;
+};
+
+const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
+
+export function useWorkOrdersTableDayView({
+ filters,
+ advActive,
+ filtered,
+ advResults,
+ onRevealed,
+}: UseWorkOrdersTableDayViewParams) {
+ const { scheduled, unscheduled } = useMemo(() => {
+ if (advActive) {
+ return splitScheduledUnscheduled(advResults);
+ }
+ return splitScheduledUnscheduled(filtered);
+ }, [advActive, advResults, filtered]);
+
+ const dayGroups = useMemo(
+ () =>
+ advActive
+ ? []
+ : [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
+ [advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
+ );
+ const pagination = useWorkOrdersDayPagination({
+ weekMonday: filters.weekMonday,
+ selectedDayIso: filters.selectedDayIso,
+ advActive,
+ dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
+ onRevealed,
+ });
+ const pagedDay = useMemo(
+ () => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
+ [dayGroups, pagination.page, pagination.pageSize],
+ );
+
+ const searchActive = filters.search.trim() !== "";
+ const displayGroups = pagedDay.groups;
+ const dayTotalCount = pagedDay.totalCount;
+ const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
+ const noSearchResults =
+ !advActive &&
+ isDayScopedSearchEmpty({
+ searchActive,
+ unassignedCount: 0,
+ dayItemCount: dayTotalCount,
+ unassignedQueueReady: true,
+ });
+ const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
+ const todayKey =
+ filters.weekMonday === currentWeekMonday()
+ ? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
+ : null;
+
+ return {
+ scheduled,
+ unscheduled,
+ displayGroups,
+ searchActive,
+ noSearchResults,
+ isEmptyWeek,
+ todayKey,
+ dayTotalCount,
+ dayVisibleCount,
+ pagination,
+ pagedDay,
+ };
+}
diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts
index 9401ba1f..9b3b853a 100644
--- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts
@@ -1,12 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "react-toastify";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { applyBoardRowOrderOnScheduleChange } from "@/domain/work-orders/utils/apply-board-row-order-on-schedule-change";
+import { applyCarryOverDisplayDayFields } from "@/domain/work-orders/utils/carry-over-display";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
+import { isWorkOrderFullyLocked } from "@/domain/work-orders/utils/work-order-lock";
import {
applyRowOrderByDay,
+ boardRowOrderStorageKey,
loadBoardRowOrder,
mergeDayOrder,
- removeIdFromRowOrder,
reorderIdsInList,
saveBoardRowOrder,
type BoardRowOrderByDay,
@@ -23,6 +27,7 @@ export type UseWorkOrdersTableLocalStateParams = {
weekMonday: string;
advActive: boolean;
isFetching: boolean;
+ ownerId?: string;
};
function usePrunePatchesOnFetchSettle(
@@ -44,6 +49,7 @@ export function useWorkOrdersTableLocalState({
weekMonday,
advActive,
isFetching,
+ ownerId = "",
}: UseWorkOrdersTableLocalStateParams) {
const [patches, setPatches] = useState>>({});
const [localRows, setLocalRows] = useState([]);
@@ -51,8 +57,19 @@ export function useWorkOrdersTableLocalState({
const ui = useWorkOrdersTableUiState(weekMonday);
useEffect(() => {
- setRowOrderByDay(loadBoardRowOrder(weekMonday));
- }, [weekMonday]);
+ setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId));
+ }, [ownerId, weekMonday]);
+
+ useEffect(() => {
+ const storageKey = boardRowOrderStorageKey(weekMonday, ownerId);
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== storageKey) return;
+ setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId));
+ toast.info("Board order was updated in another session. Refreshed.");
+ };
+ window.addEventListener("storage", onStorage);
+ return () => window.removeEventListener("storage", onStorage);
+ }, [ownerId, weekMonday]);
const apiRows = useMemo(
() => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
@@ -62,8 +79,11 @@ export function useWorkOrdersTableLocalState({
const displayRows = useMemo(() => {
const apiIds = new Set(apiRows.map((r) => String(r.id)));
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
- return applyRowOrderByDay([...apiRows, ...extra.map(enrichTableRow)], rowOrderByDay);
- }, [apiRows, localRows, rowOrderByDay]);
+ const merged = [...apiRows, ...extra.map(enrichTableRow)].map((row) =>
+ applyCarryOverDisplayDayFields(row, weekMonday),
+ );
+ return applyRowOrderByDay(merged, rowOrderByDay);
+ }, [apiRows, localRows, rowOrderByDay, weekMonday]);
const clearRowPatch = useCallback((id: string | number) => {
const key = String(id);
@@ -100,17 +120,17 @@ export function useWorkOrdersTableLocalState({
if (patch.scheduledOn === undefined || advActive) return;
- const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
- if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
-
- setRowOrderByDay((prev) => {
- const next = removeIdFromRowOrder(prev, key);
- if (next === prev) return prev;
- saveBoardRowOrder(weekMonday, next);
- return next;
- });
+ setRowOrderByDay((prev) =>
+ applyBoardRowOrderOnScheduleChange(prev, {
+ weekMonday,
+ ownerId,
+ id: key,
+ scheduledOn: patch.scheduledOn!,
+ previousDayGroup,
+ }),
+ );
},
- [advActive, displayRows, weekMonday, localRows],
+ [advActive, displayRows, localRows, ownerId, weekMonday],
);
const reorderRows = useCallback(
@@ -120,6 +140,7 @@ export function useWorkOrdersTableLocalState({
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
const overRow = displayRows.find((r) => String(r.id) === overKey);
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) return;
+ if (isWorkOrderFullyLocked(dragRow.status) || isWorkOrderFullyLocked(overRow.status)) return;
const dayGroup = dragRow.dayGroup;
const dayIds = displayRows
@@ -130,11 +151,11 @@ export function useWorkOrdersTableLocalState({
setRowOrderByDay((prev) => {
const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) };
- if (!advActive) saveBoardRowOrder(weekMonday, next);
+ if (!advActive) saveBoardRowOrder(weekMonday, next, ownerId);
return next;
});
},
- [advActive, displayRows, weekMonday],
+ [advActive, displayRows, ownerId, weekMonday],
);
const { triggerFlash } = ui;
diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts
index 713ccd84..f77d44a9 100644
--- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts
+++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts
@@ -19,9 +19,15 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
+import {
+ maybeAutoSchedulePatch,
+ mergeAutoSchedulePatch,
+} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
import { queryKeys } from "@/infra/query-key/query-key";
+export { maybeAutoSchedulePatch };
+
export type { WorkOrderTablePatch };
export type WorkOrderTablePatchOptions = {
@@ -124,10 +130,16 @@ export function useWorkOrderTableMutations(
const patchMutation = useMutation({
meta: { suppressErrorToast: true },
- mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
- enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
- onMutate: async ({ id, patch }) => {
- onPatch(id, patch);
+ mutationFn: ({
+ id,
+ persistPatch,
+ }: {
+ id: string | number;
+ patch: WorkOrderTablePatch;
+ persistPatch: WorkOrderTablePatch;
+ }) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)),
+ onMutate: async ({ id, persistPatch }) => {
+ onPatch(id, persistPatch);
},
onSuccess: (result, { id }) => {
if (result.kind === "board") {
@@ -158,15 +170,21 @@ export function useWorkOrderTableMutations(
},
});
+ const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) =>
+ mergeAutoSchedulePatch(getRow?.(id), patch);
+
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
- void patchMutation.mutateAsync({ id, patch }).then(
+ const persistPatch = resolvePatch(id, patch);
+ void patchMutation.mutateAsync({ id, patch, persistPatch }).then(
() => options?.onSuccess?.(),
(error: Error) => options?.onError?.(error),
);
};
- const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
- patchMutation.mutateAsync({ id, patch });
+ const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
+ const persistPatch = resolvePatch(id, patch);
+ return patchMutation.mutateAsync({ id, patch, persistPatch });
+ };
return {
patchField,
@@ -189,19 +207,9 @@ export function tableRowFromDetail(detail: Awaited;
const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:";
-export function boardRowOrderStorageKey(weekMonday: string): string {
+export function boardRowOrderStorageKey(weekMonday: string, ownerId = ""): string {
+ const owner = ownerId.trim() || "local";
+ return `${STORAGE_PREFIX}${owner}:${weekMonday}`;
+}
+
+export function legacyBoardRowOrderStorageKey(weekMonday: string): string {
return `${STORAGE_PREFIX}${weekMonday}`;
}
@@ -17,13 +22,9 @@ function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay {
return Object.values(value).every(isStringArray);
}
-export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
- if (typeof window === "undefined" || !weekMonday) {
- return {};
- }
-
+function readStoredOrder(key: string): BoardRowOrderByDay {
try {
- const raw = localStorage.getItem(boardRowOrderStorageKey(weekMonday));
+ const raw = localStorage.getItem(key);
if (!raw) {
return {};
}
@@ -34,13 +35,29 @@ export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
}
}
-export function saveBoardRowOrder(weekMonday: string, orderByDay: BoardRowOrderByDay): void {
+export function loadBoardRowOrder(weekMonday: string, ownerId = ""): BoardRowOrderByDay {
+ if (typeof window === "undefined" || !weekMonday) {
+ return {};
+ }
+
+ const scoped = readStoredOrder(boardRowOrderStorageKey(weekMonday, ownerId));
+ if (Object.keys(scoped).length) {
+ return scoped;
+ }
+ return readStoredOrder(legacyBoardRowOrderStorageKey(weekMonday));
+}
+
+export function saveBoardRowOrder(
+ weekMonday: string,
+ orderByDay: BoardRowOrderByDay,
+ ownerId = "",
+): void {
if (typeof window === "undefined" || !weekMonday) {
return;
}
try {
- localStorage.setItem(boardRowOrderStorageKey(weekMonday), JSON.stringify(orderByDay));
+ localStorage.setItem(boardRowOrderStorageKey(weekMonday, ownerId), JSON.stringify(orderByDay));
} catch {
// Quota exceeded or storage blocked — keep in-memory order only.
}
@@ -105,6 +122,27 @@ export function applyRowOrderByDay(
+ rows: T[],
+ row: T,
+ delta: -1 | 1,
+ isReorderTarget: (item: T) => boolean = () => true,
+): string | number | null {
+ const dayRows = rows.filter((item) => item.dayGroup === row.dayGroup);
+ const index = dayRows.findIndex((item) => String(item.id) === String(row.id));
+ if (index < 0) {
+ return null;
+ }
+ for (let cursor = index + delta; cursor >= 0 && cursor < dayRows.length; cursor += delta) {
+ const candidate = dayRows[cursor];
+ if (candidate && isReorderTarget(candidate)) {
+ return candidate.id;
+ }
+ }
+ return null;
+}
+
export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null {
const dragIdx = ids.indexOf(dragId);
const overIdx = ids.indexOf(overId);
@@ -138,6 +176,60 @@ export function removeIdFromRowOrder(
return changed ? next : orderByDay;
}
+/** Drop the id from every day, then append it to the destination day. */
+export type TransferIdToDestinationWeekOrderInput = {
+ sourceWeekMonday: string;
+ destinationWeekMonday: string;
+ ownerId: string;
+ id: string;
+ nextDayGroup: string;
+};
+
+/** Persist a moved row under the destination week when schedule crosses week boundaries. */
+export function transferIdToDestinationWeekOrder({
+ sourceWeekMonday,
+ destinationWeekMonday,
+ ownerId,
+ id,
+ nextDayGroup,
+}: TransferIdToDestinationWeekOrderInput): void {
+ if (!sourceWeekMonday || !destinationWeekMonday || !nextDayGroup) {
+ return;
+ }
+ if (sourceWeekMonday === destinationWeekMonday) {
+ return;
+ }
+
+ const sourceOrder = loadBoardRowOrder(sourceWeekMonday, ownerId);
+ const sourceWithoutId = removeIdFromRowOrder(sourceOrder, id);
+ if (sourceWithoutId !== sourceOrder) {
+ saveBoardRowOrder(sourceWeekMonday, sourceWithoutId, ownerId);
+ }
+
+ const destinationOrder = loadBoardRowOrder(destinationWeekMonday, ownerId);
+ saveBoardRowOrder(
+ destinationWeekMonday,
+ moveIdToDayOrder(destinationOrder, id, nextDayGroup),
+ ownerId,
+ );
+}
+
+export function moveIdToDayOrder(
+ orderByDay: BoardRowOrderByDay,
+ id: string,
+ nextDayGroup: string,
+): BoardRowOrderByDay {
+ if (!nextDayGroup) {
+ return removeIdFromRowOrder(orderByDay, id);
+ }
+ const without = removeIdFromRowOrder(orderByDay, id);
+ const dest = without[nextDayGroup] ?? [];
+ if (dest.includes(id)) {
+ return without;
+ }
+ return { ...without, [nextDayGroup]: [...dest, id] };
+}
+
/**
* Merge a visible day's new order into stored order: visible ids take the new sequence,
* ids that were stored but not currently visible stay after the visible block (preserving
diff --git a/src/domain/work-orders/utils/carry-over-display.ts b/src/domain/work-orders/utils/carry-over-display.ts
new file mode 100644
index 00000000..dd92b2d3
--- /dev/null
+++ b/src/domain/work-orders/utils/carry-over-display.ts
@@ -0,0 +1,109 @@
+import { compactWeekLabel } from "@/domain/work-orders/utils/list-week-utils";
+import { formatRescheduleCountLabel } from "@/domain/work-orders/utils/reschedule-indicator";
+import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
+import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
+
+const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
+const WEEKDAY_COUNT = 7;
+const FRIDAY_OFFSET = 4;
+
+export type CarryOverRow = {
+ scheduledOn: string;
+ status?: string;
+ carriedOver?: number;
+};
+
+export function isTerminalCarryOverStatus(status?: string): boolean {
+ return status === "Completed" || status === "Canceled";
+}
+
+export function shouldShowCarriedOverPill(count: number, status?: string): boolean {
+ return count >= 2 && !isTerminalCarryOverStatus(status);
+}
+
+export function toIsoDate(value?: string): string {
+ if (!value) {
+ return "";
+ }
+ const iso = value.slice(0, 10);
+ return ISO_DATE.test(iso) ? iso : "";
+}
+
+export function resolveCarryOverSourceWeek(input: {
+ originalWeek?: string;
+ originalDate?: string;
+ scheduledOn?: string;
+}): string {
+ const originalWeek = toIsoDate(input.originalWeek);
+ if (originalWeek) {
+ return mondayOf(originalWeek);
+ }
+ const originalDate = toIsoDate(input.originalDate);
+ if (originalDate) {
+ return mondayOf(originalDate);
+ }
+ const scheduledOn = toIsoDate(input.scheduledOn);
+ return scheduledOn ? mondayOf(scheduledOn) : "";
+}
+
+export function formatCarriedOverHover(count: number, sourceWeekIso?: string): string {
+ const times = formatRescheduleCountLabel(count);
+ const base = `Carried over ${times} — this WO was not completed and moved to the next period`;
+ const sourceLabel = sourceWeekIso ? compactWeekLabel(sourceWeekIso) : "";
+ if (!sourceLabel) {
+ return base;
+ }
+ return `${base}. Source week: ${sourceLabel}.`;
+}
+
+/** Non-terminal WOs scheduled before the viewed week are display carry-overs. */
+export function isCarryOverDisplayRow(row: CarryOverRow, weekMonday: string): boolean {
+ if (isTerminalCarryOverStatus(row.status)) {
+ return false;
+ }
+ const scheduled = toIsoDate(row.scheduledOn);
+ return Boolean(scheduled) && scheduled < weekMonday;
+}
+
+export function mapCarryOverDisplayDay(scheduledOn: string, weekMonday: string): string {
+ const scheduled = toIsoDate(scheduledOn);
+ if (!scheduled || !ISO_DATE.test(weekMonday)) {
+ return "";
+ }
+ const sourceMonday = mondayOf(scheduled);
+ const offsetMs =
+ new Date(`${scheduled}T12:00:00`).getTime() - new Date(`${sourceMonday}T12:00:00`).getTime();
+ const offset = Math.round(offsetMs / 86_400_000);
+ if (offset < 0 || offset >= WEEKDAY_COUNT) {
+ return weekMonday;
+ }
+ if (offset > FRIDAY_OFFSET) {
+ return weekMonday;
+ }
+ return addDaysIso(weekMonday, offset);
+}
+
+export function rowMatchesCarryOverDay(
+ row: CarryOverRow,
+ selectedDayIso: string,
+ weekMonday: string,
+): boolean {
+ if (!isCarryOverDisplayRow(row, weekMonday)) {
+ return false;
+ }
+ return mapCarryOverDisplayDay(row.scheduledOn, weekMonday) === selectedDayIso;
+}
+
+/** Aligns UI day keys with the mapped weekday so reorder stays on the displayed tab. */
+export function applyCarryOverDisplayDayFields<
+ T extends CarryOverRow & { dayGroup: string; dayLabel: string },
+>(row: T, weekMonday: string): T {
+ if (!isCarryOverDisplayRow(row, weekMonday)) {
+ return row;
+ }
+ const displayDay = mapCarryOverDisplayDay(row.scheduledOn, weekMonday);
+ if (!displayDay) {
+ return row;
+ }
+ return { ...row, ...deriveDayFields(displayDay) };
+}
diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts
index dcda43c5..4f2a045b 100644
--- a/src/domain/work-orders/utils/table-filter-utils.ts
+++ b/src/domain/work-orders/utils/table-filter-utils.ts
@@ -7,6 +7,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 { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
@@ -96,7 +97,7 @@ export function filterByBarAndSearch(
if (row.scheduledOn) {
if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
- return false;
+ return isCarryOverDisplayRow(row, bar.weekMonday);
}
} else if (row.targetWeek) {
if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {
diff --git a/src/domain/work-orders/utils/week-day-nav-utils.ts b/src/domain/work-orders/utils/week-day-nav-utils.ts
index 073cf231..f2b10778 100644
--- a/src/domain/work-orders/utils/week-day-nav-utils.ts
+++ b/src/domain/work-orders/utils/week-day-nav-utils.ts
@@ -2,6 +2,7 @@ import type {
WorkOrderDayGroup,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
+import { rowMatchesCarryOverDay } from "@/domain/work-orders/utils/carry-over-display";
import { isLiveWeek } from "@/domain/work-orders/utils/list-week-utils";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
@@ -65,10 +66,17 @@ export function formatDayTabLabel(iso: string): string {
}
export function rowMatchesSelectedDay(
- row: Pick,
+ row: Pick,
selectedDayIso: string,
+ weekMonday?: string,
): boolean {
- return Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso;
+ if (Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso) {
+ return true;
+ }
+ if (!weekMonday) {
+ return false;
+ }
+ return rowMatchesCarryOverDay(row, selectedDayIso, weekMonday);
}
export function buildSelectedDayGroup(
@@ -83,6 +91,6 @@ export function buildSelectedDayGroup(
key: `${month}/${day}`,
label: formatDayTabLabel(date),
date,
- items: scheduled.filter((row) => rowMatchesSelectedDay(row, date)),
+ items: scheduled.filter((row) => rowMatchesSelectedDay(row, date, weekMonday)),
};
}
diff --git a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts
new file mode 100644
index 00000000..264344eb
--- /dev/null
+++ b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts
@@ -0,0 +1,195 @@
+import {
+ ALL_WIZARD_STATUSES,
+ type WizardWOStatus,
+} from "@/domain/work-orders/types/work-order-wizard";
+import type {
+ WorkOrderTablePatch,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import {
+ PENDING_UPLIFT_GAP,
+ UPLIFT_PENDING_COMPLETED_TITLE,
+} from "@/domain/work-orders/utils/wo-closability";
+
+/** AAP `ALL_STATUSES` — Canceled is kebab-only. */
+export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES;
+
+/** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */
+export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
+ "Incomplete",
+ "Pending",
+ "Scheduled",
+];
+
+export const SCHEDULED_NEEDS_DATE_MSG =
+ "Set a Schedule On date before marking this work order as Scheduled.";
+
+export const PAST_DUE_BADGE_TOOLTIP =
+ "This WO missed its service window. Reschedule to unlock status updates.";
+
+export const PAST_DUE_POPOVER_BODY =
+ "This work order is past due. A new date must be scheduled before the status can be updated.";
+
+export const PAST_DUE_RESCHEDULE_CTA = "Reschedule →";
+
+export const COMPLETED_DOC_BLOCK_MSG =
+ "Generate or mark the completion document as not required before completing this work order";
+
+export const COMPLETED_MEDIA_BLOCK_MSG =
+ "Upload at least one completion photo before completing this work order.";
+
+const COMPLETE_ACTION = "completing this work order";
+
+const EMPTY_FIELD_TOKENS = new Set(["", "-", "—", "N/A", "n/a"]);
+
+const PRE_SCHEDULE_STATUSES = new Set(["Incomplete", "Pending", ""]);
+
+const FIELD_GAP_TO_AAP_LABEL: [string, string][] = [
+ ["Dispatcher", "Assigned To"],
+ ["Company", "Vendor"],
+ ["Completed Date", "Completed Date"],
+ ["Service", "Service"],
+];
+
+export type ScheduleDateInput = {
+ scheduledOn?: string;
+ scheduleWeekOnly?: boolean;
+ schedMode?: string;
+};
+
+export type StatusOptionState = {
+ disabled: boolean;
+ title?: string;
+};
+
+export function isLifecycleFieldEmpty(value?: string): boolean {
+ if (value == null) {
+ return true;
+ }
+ const trimmed = value.trim();
+ return EMPTY_FIELD_TOKENS.has(trimmed);
+}
+
+/** AAP `hasScheduleDate`: concrete day/range only — week-only does not count. */
+export function hasConcreteScheduleDate(row: ScheduleDateInput): boolean {
+ if (row.scheduleWeekOnly === true || row.schedMode === "week") {
+ return false;
+ }
+ return !isLifecycleFieldEmpty(row.scheduledOn);
+}
+
+export function isPreScheduleStatus(status: string): boolean {
+ return PRE_SCHEDULE_STATUSES.has(status);
+}
+
+export function formatAapCompletedBlockTitle(gaps: string[]): string | undefined {
+ if (gaps.length === 0) {
+ return undefined;
+ }
+
+ const fields = FIELD_GAP_TO_AAP_LABEL.filter(([gap]) => gaps.includes(gap)).map(
+ ([, label]) => label,
+ );
+
+ if (fields.length === 1 && fields[0] === "Service") {
+ return `Select a service before ${COMPLETE_ACTION}.`;
+ }
+ if (fields.length > 0) {
+ return `Fill in ${fields.join(", ")} before ${COMPLETE_ACTION}.`;
+ }
+ if (gaps.includes("WO number")) {
+ return `Assign a real APM number (not SH) before ${COMPLETE_ACTION}.`;
+ }
+ if (gaps.includes(PENDING_UPLIFT_GAP)) {
+ return UPLIFT_PENDING_COMPLETED_TITLE;
+ }
+ if (gaps.includes("Completion document")) {
+ return COMPLETED_DOC_BLOCK_MSG;
+ }
+ if (gaps.includes("At least 1 photo")) {
+ return COMPLETED_MEDIA_BLOCK_MSG;
+ }
+ return undefined;
+}
+
+export function getStatusOptionState(
+ row: ScheduleDateInput,
+ status: WizardWOStatus,
+ closabilityGaps: string[],
+): StatusOptionState {
+ if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
+ return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
+ }
+ if (status === "Completed") {
+ const title = formatAapCompletedBlockTitle(closabilityGaps);
+ if (title) {
+ return { disabled: true, title };
+ }
+ }
+ return { disabled: false };
+}
+
+/** AAP `maybeAutoSchedule` — no dispatcher requirement. */
+export function maybeAutoSchedulePatch(
+ row: ScheduleDateInput & { status?: string },
+): WorkOrderTablePatch {
+ const hasDate = hasConcreteScheduleDate(row);
+ if (hasDate && isPreScheduleStatus(row.status ?? "")) {
+ return { status: "Scheduled" };
+ }
+ if (!hasDate && row.status === "Scheduled") {
+ return { status: "Pending" };
+ }
+ return {};
+}
+
+function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean {
+ return (
+ patch.status !== undefined ||
+ patch.scheduledOn !== undefined ||
+ patch.scheduleWeekOnly !== undefined ||
+ patch.targetWeek !== undefined
+ );
+}
+
+export function mergeAutoSchedulePatch(
+ row: WorkOrderTableRow | undefined,
+ patch: WorkOrderTablePatch,
+): WorkOrderTablePatch {
+ if (!row || !patchTouchesScheduleOrStatus(patch)) {
+ return patch;
+ }
+ const merged = {
+ status: patch.status ?? row.status,
+ scheduledOn: patch.scheduledOn !== undefined ? patch.scheduledOn : row.scheduledOn,
+ scheduleWeekOnly:
+ patch.scheduleWeekOnly !== undefined ? patch.scheduleWeekOnly : row.scheduleWeekOnly,
+ };
+ return { ...patch, ...maybeAutoSchedulePatch(merged) };
+}
+
+/** Apply schedule fields and the AAP auto-status for that next schedule. */
+export function withAutoScheduleStatus(
+ current: T,
+ schedulePatch: Partial & ScheduleDateInput,
+): Partial {
+ const next = {
+ status: schedulePatch.status ?? current.status ?? "",
+ scheduledOn:
+ schedulePatch.scheduledOn !== undefined ? schedulePatch.scheduledOn : current.scheduledOn,
+ scheduleWeekOnly:
+ schedulePatch.scheduleWeekOnly !== undefined
+ ? schedulePatch.scheduleWeekOnly
+ : current.scheduleWeekOnly,
+ };
+ return { ...schedulePatch, ...maybeAutoSchedulePatch(next) };
+}
+
+/** AAP create: apply maybeAutoSchedule to the draft before POST. */
+export function resolveCreationStatus(row: ScheduleDateInput & { status: string }): WizardWOStatus {
+ const autoStatus = maybeAutoSchedulePatch(row).status;
+ if (autoStatus === "Scheduled" || autoStatus === "Pending") {
+ return autoStatus;
+ }
+ return (row.status || "Incomplete") as WizardWOStatus;
+}
diff --git a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts
index 441f39e2..674a9b63 100644
--- a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts
+++ b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts
@@ -92,4 +92,39 @@ describe("buildInlineDraftCreateInput", () => {
expect(input.assigneeId).toBe("");
expect(payload).not.toHaveProperty("assignTo");
});
+
+ it("promotes Pending to Scheduled when create has a concrete date", () => {
+ const draft = {
+ ...EMPTY_TABLE_DRAFT,
+ site: "BK5",
+ locationId: "9",
+ woNumber: "25006",
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ status: "Pending" as const,
+ scheduledOn: "2026-06-01",
+ scheduleWeekOnly: false,
+ };
+
+ const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
+ expect(input.status).toBe("Scheduled");
+ });
+
+ it("demotes Scheduled to Pending when create has no concrete date", () => {
+ const draft = {
+ ...EMPTY_TABLE_DRAFT,
+ site: "BK5",
+ locationId: "9",
+ woNumber: "25005",
+ pocName: "Jane",
+ pocPhone: "555-0100",
+ status: "Scheduled" as const,
+ scheduledOn: "",
+ scheduleWeekOnly: true,
+ targetWeek: "",
+ };
+
+ const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
+ expect(input.status).toBe("Pending");
+ });
});
diff --git a/src/test/app/(protected)/workorders/carried-over-counter-pill.test.tsx b/src/test/app/(protected)/workorders/carried-over-counter-pill.test.tsx
index 277f4a73..d0059873 100644
--- a/src/test/app/(protected)/workorders/carried-over-counter-pill.test.tsx
+++ b/src/test/app/(protected)/workorders/carried-over-counter-pill.test.tsx
@@ -18,6 +18,20 @@ describe("CarriedOverCounterPill", () => {
expect(pill).toHaveTextContent("3");
});
+ it("includes the source week in the accessible name", () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByLabelText(/Source week: 5\/4–5\/8/i)).toBeInTheDocument();
+ });
+
+ it("hides after resolution even when the counter remains", () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
it("is distinguishable from the reschedule pill beside it", () => {
renderWithProviders(
<>
diff --git a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx
index 828d260f..31c90363 100644
--- a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx
+++ b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx
@@ -31,8 +31,47 @@ describe("StatusCell closability gate", () => {
const completed = screen.getByRole("button", { name: /^completed$/i });
expect(completed.hasAttribute("disabled")).toBe(true);
- expect(completed.getAttribute("title") ?? "").toContain("Missing:");
+ expect(completed.getAttribute("title") ?? "").toContain("Fill in");
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalledWith("Completed");
});
+
+ it("does not offer Canceled", () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
+ expect(screen.queryByRole("button", { name: /^canceled$/i })).toBeNull();
+ });
+
+ it("disables Scheduled when the row has no concrete date", () => {
+ const onChangeStatus = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /pending/i }));
+ const scheduled = screen.getByRole("button", { name: /^scheduled$/i });
+ expect(scheduled).toBeDisabled();
+ fireEvent.click(scheduled);
+ expect(onChangeStatus).not.toHaveBeenCalled();
+ });
+
+ it("limits creation options to Incomplete, Pending, and Scheduled", () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
+ expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: /^en route$/i })).toBeNull();
+ });
});
diff --git a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx
index df022f15..f55a67ea 100644
--- a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx
+++ b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx
@@ -22,18 +22,20 @@ const pastDueRow = {
dueDate: "2026-08-01",
} as WorkOrderTableRow;
-describe("StatusCell Past Due copy (SH-185)", () => {
+describe("StatusCell Past Due copy", () => {
it("directs the dispatcher to update Schedule On, not Due Date", () => {
render();
const trigger = screen.getByRole("button", { name: /past due/i });
- expect(trigger.getAttribute("title") ?? "").toContain("Schedule On");
+ expect(trigger.getAttribute("title") ?? "").toContain("Reschedule to unlock status updates");
expect(trigger.getAttribute("title") ?? "").not.toMatch(/due date/i);
fireEvent.click(trigger);
- expect(screen.getByText(/update schedule on before the status can be updated/i)).toBeTruthy();
- expect(screen.getByRole("button", { name: /update schedule on/i })).toBeTruthy();
+ expect(
+ screen.getByText(/a new date must be scheduled before the status can be updated/i),
+ ).toBeTruthy();
+ expect(screen.getByRole("button", { name: /reschedule/i })).toBeTruthy();
expect(screen.queryByText(/due date/i)).toBeNull();
});
});
diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
index dd2f9d58..69b76209 100644
--- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
+++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
@@ -157,7 +157,7 @@ describe("StatusCell pending uplift closability", () => {
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
- expect(completed).toHaveAttribute("title", "Missing: Company");
+ expect(completed).toHaveAttribute("title", "Fill in Vendor before completing this work order.");
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalled();
});
@@ -227,7 +227,10 @@ describe("StatusCell pending uplift closability", () => {
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
- expect(completed).toHaveAttribute("title", "Missing: Company");
+ expect(completed).toHaveAttribute(
+ "title",
+ "Fill in Vendor before completing this work order.",
+ );
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalled();
},
@@ -254,7 +257,7 @@ describe("StatusCell pending uplift closability", () => {
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
- expect(completed).toHaveAttribute("title", "Missing: Company");
+ expect(completed).toHaveAttribute("title", "Fill in Vendor before completing this work order.");
});
it("allows Completed after a vendor patch clears leftover refused status", () => {
diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-filters-week.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-list-filters-week.test.tsx
new file mode 100644
index 00000000..223a9a96
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-work-orders-list-filters-week.test.tsx
@@ -0,0 +1,30 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
+import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
+
+vi.mock("@/hooks/use-debounce", () => ({
+ useDebounce: (value: T) => value,
+}));
+
+vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
+ workOrdersApi: {
+ patchBoardField: vi.fn(),
+ getBoard: vi.fn(),
+ },
+}));
+
+describe("useWorkOrdersListFilters week navigation", () => {
+ it("changes the viewed week without patching schedule fields", () => {
+ const { result } = renderHook(() => useWorkOrdersListFilters());
+
+ act(() => {
+ result.current.setWeekMonday("2026-05-18");
+ });
+
+ expect(result.current.weekMonday).toBe("2026-05-18");
+ expect(result.current.selectedDayIso).toBe("2026-05-18");
+ expect(result.current.boardParams.weekStart).toBe("2026-05-18");
+ expect(workOrdersApi.patchBoardField).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx
new file mode 100644
index 00000000..f99a0ea8
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx
@@ -0,0 +1,198 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import {
+ boardRowOrderStorageKey,
+ loadBoardRowOrder,
+ saveBoardRowOrder,
+} from "@/domain/work-orders/utils/board-row-order-storage";
+
+const WEEK = "2026-07-13";
+
+function makeRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: "1",
+ woNumber: "WO-1",
+ site: "BK5",
+ type: "PM",
+ severity: null,
+ dispatcherId: "u1",
+ dispatcherName: "Alex",
+ scheduledOn: "2026-07-15",
+ completedDate: "",
+ targetWeek: "2026-07-13",
+ dayGroup: "07/15",
+ dayLabel: "WED · 07/15",
+ location: "Lobby",
+ locationId: "loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "7",
+ company: "Acme",
+ tech: "Sam",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ isAddOn: false,
+ mediaCount: 0,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "v1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ additionalContacts: [],
+ ...overrides,
+ };
+}
+
+function hookParams(
+ items: WorkOrderTableRow[],
+ overrides: Partial<{ advActive: boolean; isFetching: boolean }> = {},
+) {
+ return {
+ items,
+ weekMonday: WEEK,
+ advActive: false,
+ isFetching: false,
+ ownerId: "u1",
+ ...overrides,
+ };
+}
+
+describe("useWorkOrdersTableLocalState row order", () => {
+ afterEach(() => {
+ localStorage.removeItem(boardRowOrderStorageKey(WEEK, "u1"));
+ localStorage.removeItem(boardRowOrderStorageKey(WEEK));
+ localStorage.removeItem(boardRowOrderStorageKey("2026-07-20", "u1"));
+ });
+
+ it("reorders within a day and persists when advanced search is off", async () => {
+ const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })];
+ const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items)));
+
+ await waitFor(() => {
+ expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]);
+ });
+
+ act(() => {
+ result.current.reorderRows("1", "2");
+ });
+
+ expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]);
+ expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2", "1"] });
+ });
+
+ it("does not persist a reorder onto a canceled row", async () => {
+ const items = [
+ makeRow({ id: "1" }),
+ makeRow({ id: "2", woNumber: "WO-2", status: "Canceled" }),
+ ];
+ const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items)));
+
+ await waitFor(() => {
+ expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]);
+ });
+
+ act(() => {
+ result.current.reorderRows("1", "2");
+ });
+
+ expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]);
+ expect(loadBoardRowOrder(WEEK, "u1")).toEqual({});
+ });
+
+ it("does not write storage while advanced search is active", async () => {
+ const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })];
+ const { result } = renderHook(() =>
+ useWorkOrdersTableLocalState(hookParams(items, { advActive: true })),
+ );
+
+ await waitFor(() => {
+ expect(result.current.displayRows).toHaveLength(2);
+ });
+
+ act(() => {
+ result.current.reorderRows("1", "2");
+ });
+
+ expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]);
+ expect(loadBoardRowOrder(WEEK, "u1")).toEqual({});
+ });
+
+ it("applies stored order after load", async () => {
+ saveBoardRowOrder(WEEK, { "07/15": ["2", "1"] }, "u1");
+ const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })];
+ const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items)));
+
+ await waitFor(() => {
+ expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]);
+ });
+ });
+
+ it("drops the id from the old day bucket when scheduledOn changes day", async () => {
+ saveBoardRowOrder(WEEK, { "07/15": ["1", "2"] }, "u1");
+ const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })];
+ const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items)));
+
+ await waitFor(() => {
+ expect(result.current.displayRows).toHaveLength(2);
+ });
+
+ act(() => {
+ result.current.patchRow("1", { scheduledOn: "2026-07-16" });
+ });
+
+ expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2"], "07/16": ["1"] });
+ });
+
+ it("writes cross-week schedule moves under the destination week key", async () => {
+ saveBoardRowOrder(WEEK, { "07/15": ["1", "2"] }, "u1");
+ const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })];
+ const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items)));
+
+ await waitFor(() => {
+ expect(result.current.displayRows).toHaveLength(2);
+ });
+
+ act(() => {
+ result.current.patchRow("1", { scheduledOn: "2026-07-20" });
+ });
+
+ expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2"] });
+ expect(loadBoardRowOrder("2026-07-20", "u1")).toEqual({ "07/20": ["1"] });
+ });
+
+ it("reloads stored order when another session writes the same key", async () => {
+ const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })];
+ const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items)));
+
+ await waitFor(() => {
+ expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]);
+ });
+
+ saveBoardRowOrder(WEEK, { "07/15": ["2", "1"] }, "u1");
+ act(() => {
+ window.dispatchEvent(
+ new StorageEvent("storage", { key: boardRowOrderStorageKey(WEEK, "u1") }),
+ );
+ });
+
+ expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]);
+ });
+});
diff --git a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx
index a79ac2e6..11c2d05d 100644
--- a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx
+++ b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx
@@ -52,6 +52,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
+ status: "Scheduled",
});
});
@@ -86,6 +87,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
+ status: "Scheduled",
});
});
diff --git a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx
index e2d04458..9280a735 100644
--- a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx
+++ b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx
@@ -19,28 +19,25 @@ describe("WizardStepVendorTime Appt Time", () => {
});
});
-describe("WizardStepVendorTime closability gate", () => {
- it("disables Completed when closability requirements are missing", () => {
- const onUpdate = vi.fn();
+describe("WizardStepVendorTime creation statuses (AAP)", () => {
+ it("offers only Incomplete, Pending, and Scheduled", () => {
render(
,
);
- fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
- const completed = screen.getByRole("button", { name: /^completed$/i });
- expect(completed).toBeDisabled();
- expect(completed).toHaveAttribute("title", expect.stringContaining("Missing:"));
-
- fireEvent.click(completed);
- expect(onUpdate).not.toHaveBeenCalledWith("status", "Completed");
+ fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
+ expect(screen.getByRole("button", { name: /^pending$/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^scheduled$/i })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^completed$/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^en route$/i })).not.toBeInTheDocument();
});
- it("allows selecting a non-Completed status", () => {
+ it("disables Scheduled without a concrete date", () => {
const onUpdate = vi.fn();
render(
{
/>,
);
+ fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
+ const scheduled = screen.getByRole("button", { name: /^scheduled$/i });
+ expect(scheduled).toBeDisabled();
+ expect(scheduled).toHaveAttribute(
+ "title",
+ "Set a Schedule On date before marking this work order as Scheduled.",
+ );
+ fireEvent.click(scheduled);
+ expect(onUpdate).not.toHaveBeenCalledWith("status", "Scheduled");
+ });
+
+ it("allows selecting a non-Completed status", () => {
+ const onPatch = vi.fn();
+ render(
+ ,
+ );
+
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
const menu = screen.getByRole("button", { name: /^pending$/i }).closest("div");
expect(menu).not.toBeNull();
fireEvent.click(within(menu!.parentElement!).getByRole("button", { name: /^pending$/i }));
- expect(onUpdate).toHaveBeenCalledWith("status", "Pending");
+ expect(onPatch).toHaveBeenCalledWith({ status: "Pending" });
+ });
+
+ it("promotes Pending to Scheduled when picked on a dated draft", () => {
+ const onPatch = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
+ fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
+ expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" });
});
});
diff --git a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx
new file mode 100644
index 00000000..836eb62b
--- /dev/null
+++ b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx
@@ -0,0 +1,147 @@
+import { fireEvent, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { WoTableRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
+import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { renderWithProviders } from "@/test/test-utils";
+
+function makeRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: "1",
+ woNumber: "WO-1",
+ site: "BK5",
+ type: "PM",
+ severity: null,
+ dispatcherId: "u1",
+ dispatcherName: "Alex",
+ scheduledOn: "2026-07-15",
+ completedDate: "",
+ targetWeek: "2026-07-13",
+ dayGroup: "07/15",
+ dayLabel: "WED · 07/15",
+ location: "Lobby",
+ locationId: "loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "7",
+ company: "Acme",
+ tech: "Sam",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ isAddOn: false,
+ mediaCount: 0,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "v1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ additionalContacts: [],
+ ...overrides,
+ };
+}
+
+function handlers(overrides: Partial = {}): WoTableRowHandlers {
+ return {
+ onRowClick: vi.fn(),
+ onView: vi.fn(),
+ onEdit: vi.fn(),
+ onOpenMedia: vi.fn(),
+ onPatch: vi.fn(),
+ onScheduleDate: vi.fn(),
+ onScheduleWeek: vi.fn(),
+ onOpenSlideOver: vi.fn(),
+ onReorder: vi.fn(),
+ onDragStart: vi.fn(),
+ onDragOver: vi.fn(),
+ onDrop: vi.fn(),
+ onDragEnd: vi.fn(),
+ ...overrides,
+ };
+}
+
+function renderRow(
+ row: WorkOrderTableRow,
+ allRows: WorkOrderTableRow[],
+ rowHandlers: WoTableRowHandlers,
+) {
+ return renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+}
+
+describe("WoTableRow keyboard reorder", () => {
+ it("reorders down and up with arrow keys on the grip", () => {
+ const onReorder = vi.fn();
+ const first = makeRow({ id: "1", woNumber: "WO-1" });
+ const second = makeRow({ id: "2", woNumber: "WO-2" });
+ renderRow(first, [first, second], handlers({ onReorder }));
+
+ fireEvent.keyDown(screen.getByRole("button", { name: "Reorder work order WO-1" }), {
+ key: "ArrowDown",
+ });
+ expect(onReorder).toHaveBeenCalledWith("1", "2");
+
+ onReorder.mockClear();
+ fireEvent.keyDown(screen.getByRole("button", { name: "Reorder work order WO-1" }), {
+ key: "ArrowUp",
+ });
+ expect(onReorder).not.toHaveBeenCalled();
+ });
+
+ it("does not reorder a canceled row from the grip", () => {
+ const onReorder = vi.fn();
+ const row = makeRow({ status: "Canceled" });
+ renderRow(row, [row], handlers({ onReorder }));
+
+ const grip = screen.getByRole("button", { name: "Reorder work order WO-1" });
+ expect(grip).toHaveAttribute("aria-disabled", "true");
+ expect(grip).toHaveAttribute("tabindex", "-1");
+ fireEvent.keyDown(grip, { key: "ArrowDown" });
+ expect(onReorder).not.toHaveBeenCalled();
+ });
+
+ it("skips a canceled neighbor when reordering an active row", () => {
+ const onReorder = vi.fn();
+ const first = makeRow({ id: "1", woNumber: "WO-1" });
+ const canceled = makeRow({ id: "2", woNumber: "WO-2", status: "Canceled" });
+ const third = makeRow({ id: "3", woNumber: "WO-3" });
+ renderRow(first, [first, canceled, third], handlers({ onReorder }));
+
+ fireEvent.keyDown(screen.getByRole("button", { name: "Reorder work order WO-1" }), {
+ key: "ArrowDown",
+ });
+ expect(onReorder).toHaveBeenCalledWith("1", "3");
+ });
+});
diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts
index b0208e84..81b8e3e8 100644
--- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
+import { buildSelectedDayGroup } from "@/domain/work-orders/utils/week-day-nav-utils";
describe("mapWorkOrderTableRow isAddOn", () => {
it("reads isAddOn from board DTO when present", () => {
@@ -137,3 +138,32 @@ describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
expect(row.primaryDispatchId).toBe(11);
});
});
+
+describe("mapWorkOrderTableRow carry-over fields", () => {
+ it("maps carriedOver and originalWeek without rewriting scheduledOn", () => {
+ const row = mapWorkOrderTableRow({
+ id: 41,
+ workOrderType: WORK_ORDER_TYPE_API.PM,
+ scheduledDate: "2026-05-13",
+ carriedOver: 2,
+ originalWeek: "2026-05-11",
+ });
+ expect(row.carriedOver).toBe(2);
+ expect(row.originalWeek).toBe("2026-05-11");
+ expect(row.scheduledOn).toBe("2026-05-13");
+ });
+
+ it("shows an unresolved prior-week row on the next week's matching day tab", () => {
+ const row = mapWorkOrderTableRow({
+ id: 42,
+ workOrderType: WORK_ORDER_TYPE_API.PM,
+ scheduledDate: "2026-05-13",
+ lifecycleStatus: 3,
+ carriedOver: 2,
+ originalWeek: "2026-05-11",
+ });
+ const group = buildSelectedDayGroup("2026-05-18", "2026-05-20", [row]);
+ expect(row.status).toBe("Scheduled");
+ expect(group.items.map((item) => item.id)).toEqual([42]);
+ });
+});
diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
index 4edb9175..6741b590 100644
--- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
+++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
@@ -29,6 +29,7 @@ import {
applySchedulePatch,
useWorkOrderTableMutations,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
+import { queryKeys } from "@/infra/query-key/query-key";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import {
getClosabilityGaps,
@@ -224,6 +225,43 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
});
});
+describe("maybeAutoSchedule on board patch", () => {
+ it("optimistically promotes Incomplete to Scheduled when a date is set", async () => {
+ const rowState = { ...BASE_ROW, status: "Incomplete" as const, scheduledOn: "" };
+ patchBoardField.mockImplementation(async (_id: unknown, payload: { field: string }) => {
+ if (payload.field === "lifecycleStatus") {
+ return { ...rowState, status: "Scheduled", scheduledOn: "2026-07-15" };
+ }
+ return { ...rowState, scheduledOn: "2026-07-15" };
+ });
+
+ const onPatch = vi.fn();
+ const { result } = renderHook(
+ () =>
+ useWorkOrderTableMutations({
+ onPatch,
+ clearPatch: () => undefined,
+ getRow: () => rowState,
+ }),
+ { wrapper: makeWrapper() },
+ );
+
+ act(() => {
+ result.current.patchField(42, applySchedulePatch("2026-07-15", false));
+ });
+
+ expect(onPatch).toHaveBeenCalledWith(
+ 42,
+ expect.objectContaining({ scheduledOn: "2026-07-15", status: "Scheduled" }),
+ );
+ await waitFor(() => expect(patchBoardField).toHaveBeenCalled());
+ expect(patchBoardField).toHaveBeenCalledWith(
+ 42,
+ expect.objectContaining({ field: "lifecycleStatus", value: "Scheduled" }),
+ );
+ });
+});
+
describe("applySchedulePatch isAddOn optimistic clear", () => {
it("clears isAddOn when schedule is cleared", () => {
expect(applySchedulePatch("", false)).toMatchObject({
@@ -750,3 +788,39 @@ describe("useWorkOrderTableMutations type and severity", () => {
await waitFor(() => expect(clearPatch).toHaveBeenCalledWith(42));
});
});
+
+describe("useWorkOrderTableMutations audit cache refresh", () => {
+ it("invalidates work-order queries after a successful status transition", async () => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const invalidateSpy = vi.spyOn(client, "invalidateQueries");
+ const nextRow = { ...BASE_ROW, status: "En Route" as const, rowVersion: "wo-v2" };
+ patchBoardField.mockReset();
+ patchBoardField.mockResolvedValue(nextRow);
+
+ const { result } = renderHook(
+ () =>
+ useWorkOrderTableMutations({
+ onPatch: vi.fn(),
+ clearPatch: () => undefined,
+ getRow: () => BASE_ROW,
+ }),
+ {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ },
+ );
+
+ act(() => {
+ result.current.patchField(42, { status: "En Route" });
+ });
+
+ await waitFor(() =>
+ expect(invalidateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ queryKey: queryKeys.workOrders.all }),
+ ),
+ );
+ });
+});
diff --git a/src/test/domain/work-orders/utils/board-row-order-storage.test.ts b/src/test/domain/work-orders/utils/board-row-order-storage.test.ts
index 28c326cf..63d9f984 100644
--- a/src/test/domain/work-orders/utils/board-row-order-storage.test.ts
+++ b/src/test/domain/work-orders/utils/board-row-order-storage.test.ts
@@ -5,9 +5,13 @@ import {
boardRowOrderStorageKey,
loadBoardRowOrder,
mergeDayOrder,
+ moveIdToDayOrder,
removeIdFromRowOrder,
+ neighborIdForReorder,
+ legacyBoardRowOrderStorageKey,
reorderIdsInList,
saveBoardRowOrder,
+ transferIdToDestinationWeekOrder,
} from "@/domain/work-orders/utils/board-row-order-storage";
describe("applyDayRowOrder", () => {
@@ -47,6 +51,36 @@ describe("applyRowOrderByDay", () => {
});
});
+describe("neighborIdForReorder", () => {
+ const rows = [
+ { id: "a", dayGroup: "07/14" },
+ { id: "b", dayGroup: "07/14" },
+ { id: "c", dayGroup: "07/15" },
+ ];
+
+ it("returns the previous and next id in the same day", () => {
+ expect(neighborIdForReorder(rows, rows[1]!, -1)).toBe("a");
+ expect(neighborIdForReorder(rows, rows[0]!, 1)).toBe("b");
+ });
+
+ it("returns null at the ends of a day or when the row is missing", () => {
+ expect(neighborIdForReorder(rows, rows[0]!, -1)).toBeNull();
+ expect(neighborIdForReorder(rows, rows[2]!, 1)).toBeNull();
+ expect(neighborIdForReorder(rows, { id: "x", dayGroup: "07/14" }, 1)).toBeNull();
+ });
+
+ it("skips neighbors that fail isReorderTarget", () => {
+ const dayRows = [
+ { id: "a", dayGroup: "07/14", locked: false },
+ { id: "b", dayGroup: "07/14", locked: true },
+ { id: "c", dayGroup: "07/14", locked: false },
+ ];
+ expect(neighborIdForReorder(dayRows, dayRows[0]!, 1, (item) => !item.locked)).toBe("c");
+ expect(neighborIdForReorder(dayRows, dayRows[2]!, -1, (item) => !item.locked)).toBe("a");
+ expect(neighborIdForReorder(dayRows, dayRows[0]!, 1, () => false)).toBeNull();
+ });
+});
+
describe("reorderIdsInList", () => {
it("moves drag id to over index", () => {
expect(reorderIdsInList(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]);
@@ -84,12 +118,50 @@ describe("removeIdFromRowOrder", () => {
});
});
+describe("moveIdToDayOrder", () => {
+ it("moves an id to the end of the destination day", () => {
+ expect(moveIdToDayOrder({ "07/15": ["1", "2"], "07/16": ["3"] }, "1", "07/16")).toEqual({
+ "07/15": ["2"],
+ "07/16": ["3", "1"],
+ });
+ });
+});
+
+describe("transferIdToDestinationWeekOrder", () => {
+ const sourceWeek = "2026-07-13";
+ const destinationWeek = "2026-07-20";
+ const ownerKeySource = boardRowOrderStorageKey(sourceWeek, "u1");
+ const ownerKeyDestination = boardRowOrderStorageKey(destinationWeek, "u1");
+
+ afterEach(() => {
+ localStorage.removeItem(ownerKeySource);
+ localStorage.removeItem(ownerKeyDestination);
+ });
+
+ it("removes the id from the source week and appends it to the destination week day bucket", () => {
+ saveBoardRowOrder(sourceWeek, { "07/15": ["1", "2"] }, "u1");
+ transferIdToDestinationWeekOrder({
+ sourceWeekMonday: sourceWeek,
+ destinationWeekMonday: destinationWeek,
+ ownerId: "u1",
+ id: "1",
+ nextDayGroup: "07/20",
+ });
+ expect(loadBoardRowOrder(sourceWeek, "u1")).toEqual({ "07/15": ["2"] });
+ expect(loadBoardRowOrder(destinationWeek, "u1")).toEqual({ "07/20": ["1"] });
+ });
+});
+
describe("loadBoardRowOrder / saveBoardRowOrder", () => {
const week = "2026-07-13";
const key = boardRowOrderStorageKey(week);
+ const ownerKey = boardRowOrderStorageKey(week, "u1");
+ const legacyKey = legacyBoardRowOrderStorageKey(week);
afterEach(() => {
localStorage.removeItem(key);
+ localStorage.removeItem(ownerKey);
+ localStorage.removeItem(legacyKey);
vi.restoreAllMocks();
});
@@ -99,6 +171,19 @@ describe("loadBoardRowOrder / saveBoardRowOrder", () => {
expect(loadBoardRowOrder(week)).toEqual(order);
});
+ it("reads the legacy unscoped key when the owner key is empty", () => {
+ const order = { "07/14": ["9"] };
+ localStorage.setItem(legacyKey, JSON.stringify(order));
+ expect(loadBoardRowOrder(week, "u1")).toEqual(order);
+ });
+
+ it("keeps owner-scoped maps isolated", () => {
+ saveBoardRowOrder(week, { "07/14": ["1"] }, "u1");
+ saveBoardRowOrder(week, { "07/14": ["2"] }, "u2");
+ expect(loadBoardRowOrder(week, "u1")).toEqual({ "07/14": ["1"] });
+ expect(loadBoardRowOrder(week, "u2")).toEqual({ "07/14": ["2"] });
+ });
+
it("returns empty object for invalid JSON payload", () => {
localStorage.setItem(key, "{not-json");
expect(loadBoardRowOrder(week)).toEqual({});
diff --git a/src/test/domain/work-orders/utils/carry-over-display.test.ts b/src/test/domain/work-orders/utils/carry-over-display.test.ts
new file mode 100644
index 00000000..00b54dfe
--- /dev/null
+++ b/src/test/domain/work-orders/utils/carry-over-display.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vitest";
+import {
+ applyCarryOverDisplayDayFields,
+ formatCarriedOverHover,
+ isCarryOverDisplayRow,
+ mapCarryOverDisplayDay,
+ resolveCarryOverSourceWeek,
+ shouldShowCarriedOverPill,
+} from "@/domain/work-orders/utils/carry-over-display";
+
+describe("carry-over-display", () => {
+ it("hides the pill below 2 and on terminal statuses", () => {
+ expect(shouldShowCarriedOverPill(1, "Scheduled")).toBe(false);
+ expect(shouldShowCarriedOverPill(2, "Scheduled")).toBe(true);
+ expect(shouldShowCarriedOverPill(3, "Completed")).toBe(false);
+ expect(shouldShowCarriedOverPill(3, "Canceled")).toBe(false);
+ });
+
+ it("formats hover without a source week", () => {
+ expect(formatCarriedOverHover(2)).toBe(
+ "Carried over 2 times — this WO was not completed and moved to the next period",
+ );
+ });
+
+ it("appends the source week label from originalWeek", () => {
+ expect(formatCarriedOverHover(3, "2026-05-04")).toBe(
+ "Carried over 3 times — this WO was not completed and moved to the next period. Source week: 5/4–5/8.",
+ );
+ });
+
+ it("resolves source week from originalWeek, then originalDate, then scheduledOn", () => {
+ expect(resolveCarryOverSourceWeek({ originalWeek: "2026-05-04" })).toBe("2026-05-04");
+ expect(resolveCarryOverSourceWeek({ originalDate: "2026-05-06" })).toBe("2026-05-04");
+ expect(resolveCarryOverSourceWeek({ scheduledOn: "2026-05-13" })).toBe("2026-05-11");
+ expect(resolveCarryOverSourceWeek({})).toBe("");
+ });
+
+ it("treats non-terminal rows scheduled before the viewed week as carry-over", () => {
+ expect(
+ isCarryOverDisplayRow(
+ { scheduledOn: "2026-05-13", status: "Scheduled", carriedOver: 1 },
+ "2026-05-18",
+ ),
+ ).toBe(true);
+ expect(
+ isCarryOverDisplayRow(
+ { scheduledOn: "2026-05-20", status: "Scheduled", carriedOver: 2 },
+ "2026-05-18",
+ ),
+ ).toBe(false);
+ expect(
+ isCarryOverDisplayRow(
+ { scheduledOn: "2026-05-13", status: "Completed", carriedOver: 2 },
+ "2026-05-18",
+ ),
+ ).toBe(false);
+ });
+
+ it("maps the original weekday onto the viewed week and weekends onto Monday", () => {
+ expect(mapCarryOverDisplayDay("2026-05-13", "2026-05-18")).toBe("2026-05-20");
+ expect(mapCarryOverDisplayDay("2026-05-16", "2026-05-18")).toBe("2026-05-18");
+ expect(mapCarryOverDisplayDay("2026-05-17", "2026-05-18")).toBe("2026-05-18");
+ });
+
+ it("remaps dayGroup to the viewed weekday without changing scheduledOn", () => {
+ const row = {
+ scheduledOn: "2026-05-13",
+ status: "Scheduled",
+ dayGroup: "05/13",
+ dayLabel: "WED · 05/13",
+ };
+ const next = applyCarryOverDisplayDayFields(row, "2026-05-18");
+ expect(next.scheduledOn).toBe("2026-05-13");
+ expect(next.dayGroup).toBe("05/20");
+ expect(next.dayLabel).toBe("WED · 05/20");
+ });
+
+ it("leaves in-week and terminal rows' dayGroup unchanged", () => {
+ const inWeek = {
+ scheduledOn: "2026-05-20",
+ status: "Scheduled",
+ dayGroup: "05/20",
+ dayLabel: "WED · 05/20",
+ };
+ const completed = {
+ scheduledOn: "2026-05-13",
+ status: "Completed",
+ dayGroup: "05/13",
+ dayLabel: "WED · 05/13",
+ };
+ expect(applyCarryOverDisplayDayFields(inWeek, "2026-05-18")).toEqual(inWeek);
+ expect(applyCarryOverDisplayDayFields(completed, "2026-05-18")).toEqual(completed);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts
index 9e9d7056..8c9ff415 100644
--- a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts
+++ b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts
@@ -21,7 +21,28 @@ describe("formatBoardAuditAction", () => {
oldValue: "Scheduled",
newValue: "Incomplete",
}),
- ).toBe('Changed status to "Incomplete"');
+ ).toBe('Changed status from "Scheduled" to "Incomplete"');
+ });
+
+ it("includes the previous status in the audit sentence when present", () => {
+ const sentence = formatBoardAuditAction({
+ action: "StatusChanged",
+ fieldName: "LifecycleStatus",
+ oldValue: "Pending",
+ newValue: "En Route",
+ });
+ expect(sentence).toBe('Changed status from "Pending" to "En Route"');
+ });
+
+ it("omits from when the previous status is empty", () => {
+ expect(
+ formatBoardAuditAction({
+ action: "StatusChanged",
+ fieldName: "LifecycleStatus",
+ oldValue: "",
+ newValue: "Scheduled",
+ }),
+ ).toBe('Changed status to "Scheduled"');
});
it("formats WeekRolled / CarriedOver with increment", () => {
diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
index 5a5bfcc2..b6a6c416 100644
--- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts
+++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
@@ -137,6 +137,28 @@ describe("filterByBarAndSearch", () => {
expect(ids).toEqual(["in"]);
});
+ it("keeps prior-week unresolved carry-over rows that match a one-character search", () => {
+ const carried = makeRow({
+ id: "co",
+ woNumber: "WO-CARRY",
+ scheduledOn: "2026-05-13",
+ status: "Scheduled",
+ ...deriveDayFields("2026-05-13"),
+ });
+ const completed = makeRow({
+ id: "done",
+ woNumber: "WO-DONE",
+ scheduledOn: "2026-05-13",
+ status: "Completed",
+ ...deriveDayFields("2026-05-13"),
+ });
+
+ const ids = filterByBarAndSearch([carried, completed], bar("2026-05-18"), "W").map(
+ (row) => row.id,
+ );
+ expect(ids).toEqual(["co"]);
+ });
+
it("keeps the union of selected types (OR)", () => {
const pm = makeRow({ id: "pm", type: "PM", scheduledOn: "2026-06-03" });
const emergency = makeRow({ id: "em", type: "Emergency", scheduledOn: "2026-06-03" });
diff --git a/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts b/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
index 0439dc61..ec6c9bc3 100644
--- a/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
+++ b/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
@@ -179,3 +179,44 @@ describe("week-day-nav-utils (SH-240)", () => {
).toBe(false);
});
});
+
+describe("week-day-nav-utils carry-over display", () => {
+ it("places a prior-week unresolved WO on the same weekday of the viewed week", () => {
+ const carried = row({
+ id: "co",
+ scheduledOn: "2026-05-13",
+ dayGroup: "05/13",
+ status: "Scheduled",
+ carriedOver: 2,
+ originalWeek: "2026-05-11",
+ });
+ const nativeWed = row({
+ id: "native",
+ scheduledOn: "2026-05-20",
+ dayGroup: "05/20",
+ status: "Scheduled",
+ });
+
+ expect(rowMatchesSelectedDay(carried, "2026-05-20", "2026-05-18")).toBe(true);
+ expect(rowMatchesSelectedDay(carried, "2026-05-18", "2026-05-18")).toBe(false);
+
+ const group = buildSelectedDayGroup("2026-05-18", "2026-05-20", [carried, nativeWed]);
+ expect(group.items.map((item) => item.id)).toEqual(["co", "native"]);
+ });
+
+ it("does not map completed carry-overs onto the viewed week", () => {
+ const completed = row({
+ id: "done",
+ scheduledOn: "2026-05-13",
+ status: "Completed",
+ carriedOver: 2,
+ });
+ expect(rowMatchesSelectedDay(completed, "2026-05-20", "2026-05-18")).toBe(false);
+ expect(buildSelectedDayGroup("2026-05-18", "2026-05-20", [completed]).items).toEqual([]);
+ });
+
+ it("keeps unscheduled rows out of day groups", () => {
+ const unscheduled = row({ id: "us", scheduledOn: "", scheduleWeekOnly: true, carriedOver: 2 });
+ expect(rowMatchesSelectedDay(unscheduled, "2026-05-18", "2026-05-18")).toBe(false);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts
new file mode 100644
index 00000000..5e99b7a1
--- /dev/null
+++ b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts
@@ -0,0 +1,202 @@
+import { describe, expect, it } from "vitest";
+import { PENDING_UPLIFT_GAP } from "@/domain/work-orders/utils/wo-closability";
+import {
+ BOARD_INLINE_STATUSES,
+ COMPLETED_DOC_BLOCK_MSG,
+ COMPLETED_MEDIA_BLOCK_MSG,
+ CREATION_INLINE_STATUSES,
+ SCHEDULED_NEEDS_DATE_MSG,
+ formatAapCompletedBlockTitle,
+ getStatusOptionState,
+ hasConcreteScheduleDate,
+ maybeAutoSchedulePatch,
+ mergeAutoSchedulePatch,
+ resolveCreationStatus,
+ withAutoScheduleStatus,
+} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+describe("wo-lifecycle-transitions", () => {
+ it("lists AAP board statuses without Canceled", () => {
+ expect(BOARD_INLINE_STATUSES).toEqual([
+ "Incomplete",
+ "Pending",
+ "Scheduled",
+ "En Route",
+ "On Site",
+ "In Progress",
+ "Completed",
+ "Rescheduled",
+ "Pending Quote",
+ ]);
+ expect(BOARD_INLINE_STATUSES).not.toContain("Canceled");
+ });
+
+ it("lists AAP creation statuses only", () => {
+ expect(CREATION_INLINE_STATUSES).toEqual(["Incomplete", "Pending", "Scheduled"]);
+ });
+
+ it("treats week-only as no concrete schedule date", () => {
+ expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01", scheduleWeekOnly: true })).toBe(
+ false,
+ );
+ expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01", schedMode: "week" })).toBe(false);
+ expect(hasConcreteScheduleDate({ scheduledOn: "—" })).toBe(false);
+ expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01" })).toBe(true);
+ });
+
+ it("disables Scheduled without a concrete date", () => {
+ expect(getStatusOptionState({ scheduledOn: "" }, "Scheduled", [])).toEqual({
+ disabled: true,
+ title: SCHEDULED_NEEDS_DATE_MSG,
+ });
+ });
+
+ it("promotes Incomplete/Pending when a concrete date exists", () => {
+ expect(
+ maybeAutoSchedulePatch({
+ status: "Incomplete",
+ scheduledOn: "2026-06-01",
+ scheduleWeekOnly: false,
+ }),
+ ).toEqual({ status: "Scheduled" });
+ expect(
+ maybeAutoSchedulePatch({
+ status: "Pending",
+ scheduledOn: "2026-06-01",
+ scheduleWeekOnly: false,
+ }),
+ ).toEqual({ status: "Scheduled" });
+ });
+
+ it("reverts Scheduled without a concrete date to Pending", () => {
+ expect(
+ maybeAutoSchedulePatch({
+ status: "Scheduled",
+ scheduledOn: "",
+ scheduleWeekOnly: false,
+ }),
+ ).toEqual({ status: "Pending" });
+ });
+
+ it("does not auto-change In Progress when a date exists", () => {
+ expect(
+ maybeAutoSchedulePatch({
+ status: "In Progress",
+ scheduledOn: "2026-06-01",
+ scheduleWeekOnly: false,
+ }),
+ ).toEqual({});
+ });
+
+ it("merges auto-schedule onto an existing-row patch", () => {
+ const row = {
+ status: "Incomplete",
+ scheduledOn: "",
+ scheduleWeekOnly: false,
+ } as WorkOrderTableRow;
+ expect(
+ mergeAutoSchedulePatch(row, { scheduledOn: "2026-06-02", scheduleWeekOnly: false }),
+ ).toEqual({
+ scheduledOn: "2026-06-02",
+ scheduleWeekOnly: false,
+ status: "Scheduled",
+ });
+ expect(mergeAutoSchedulePatch(row, { vendorId: "9" })).toEqual({ vendorId: "9" });
+ expect(
+ mergeAutoSchedulePatch({ ...row, scheduledOn: "2026-06-01" }, { vendorId: "9" }),
+ ).toEqual({ vendorId: "9" });
+ });
+
+ it("promotes a pre-schedule wizard pick to Scheduled on a dated draft", () => {
+ const draft = {
+ status: "Scheduled",
+ scheduledOn: "2026-06-01",
+ scheduleWeekOnly: false,
+ };
+ expect(withAutoScheduleStatus(draft, { status: "Pending" })).toEqual({ status: "Scheduled" });
+ expect(withAutoScheduleStatus(draft, { status: "Incomplete" })).toEqual({
+ status: "Scheduled",
+ });
+ });
+
+ it("still derives Scheduled when a concrete date is set on a draft", () => {
+ const draft = {
+ status: "Pending",
+ scheduledOn: "",
+ scheduleWeekOnly: false,
+ };
+ expect(
+ withAutoScheduleStatus(draft, { scheduledOn: "2026-06-02", scheduleWeekOnly: false }),
+ ).toEqual({
+ scheduledOn: "2026-06-02",
+ scheduleWeekOnly: false,
+ status: "Scheduled",
+ });
+ });
+
+ it("promotes pre-schedule picks on dated rows", () => {
+ const row = {
+ status: "Scheduled",
+ scheduledOn: "2026-06-01",
+ scheduleWeekOnly: false,
+ } as WorkOrderTableRow;
+ expect(mergeAutoSchedulePatch(row, { status: "Pending" })).toEqual({ status: "Scheduled" });
+ expect(mergeAutoSchedulePatch(row, { status: "Incomplete" })).toEqual({
+ status: "Scheduled",
+ });
+ });
+
+ it("still demotes Scheduled when the schedule is cleared", () => {
+ const row = {
+ status: "Scheduled",
+ scheduledOn: "2026-06-01",
+ scheduleWeekOnly: false,
+ } as WorkOrderTableRow;
+ expect(mergeAutoSchedulePatch(row, { scheduledOn: "", scheduleWeekOnly: false })).toEqual({
+ scheduledOn: "",
+ scheduleWeekOnly: false,
+ status: "Pending",
+ });
+ });
+
+ it("promotes a Pending create pick to Scheduled when the draft is dated", () => {
+ expect(
+ resolveCreationStatus({
+ status: "Pending",
+ scheduledOn: "2026-06-01",
+ scheduleWeekOnly: false,
+ }),
+ ).toBe("Scheduled");
+ });
+
+ it("demotes Scheduled without a concrete date at create", () => {
+ expect(
+ resolveCreationStatus({
+ status: "Scheduled",
+ scheduledOn: "",
+ scheduleWeekOnly: false,
+ }),
+ ).toBe("Pending");
+ });
+
+ it("uses AAP completed tooltip hierarchy", () => {
+ expect(formatAapCompletedBlockTitle(["Dispatcher", "Company"])).toBe(
+ "Fill in Assigned To, Vendor before completing this work order.",
+ );
+ expect(formatAapCompletedBlockTitle(["Service"])).toBe(
+ "Select a service before completing this work order.",
+ );
+ expect(formatAapCompletedBlockTitle(["WO number"])).toBe(
+ "Assign a real APM number (not SH) before completing this work order.",
+ );
+ expect(formatAapCompletedBlockTitle([PENDING_UPLIFT_GAP])).toBe(
+ "This work order has an uplift pending approval and cannot be completed.",
+ );
+ expect(formatAapCompletedBlockTitle(["Completion document"])).toBe(COMPLETED_DOC_BLOCK_MSG);
+ expect(formatAapCompletedBlockTitle(["At least 1 photo"])).toBe(COMPLETED_MEDIA_BLOCK_MSG);
+ expect(formatAapCompletedBlockTitle(["Company", PENDING_UPLIFT_GAP])).toBe(
+ "Fill in Vendor before completing this work order.",
+ );
+ });
+});
|