Merge branch 'dev' into feat/sh-298-sentry

This commit is contained in:
Alexandre Brandizzi 2026-09-04 14:23:41 -03:00 • committed by GitHub
commit d6163e7eb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1199 additions and 121 deletions

View file

@ -50,6 +50,9 @@ export function SlideOverInfoTabEditView({
rescheduleCount={draft.rescheduleCount}
carriedOver={draft.carriedOver}
originalDate={draft.originalDate}
originalWeek={draft.originalWeek}
scheduledOn={draft.scheduledOn}
status={draft.status}
currentId={draft.id}
allOrders={allOrders}
onSave={(woNumber) => onDraftChange({ woNumber })}

View file

@ -13,6 +13,9 @@ type EditableWONumberCellProps = {
carriedOver?: number;
isAddOn?: boolean;
originalDate?: string;
originalWeek?: string;
scheduledOn?: string;
status?: string;
q?: string;
currentId: string | number;
allOrders: WorkOrderTableRow[];
@ -37,6 +40,9 @@ export function EditableWONumberCell({
carriedOver = 0,
isAddOn = false,
originalDate,
originalWeek,
scheduledOn,
status,
q,
currentId,
allOrders,
@ -118,7 +124,13 @@ export function EditableWONumberCell({
</span>
<AddOnPill when={isAddOn} />
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
<CarriedOverCounterPill count={carriedOver} />
<CarriedOverCounterPill
count={carriedOver}
originalWeek={originalWeek}
originalDate={originalDate}
scheduledOn={scheduledOn}
status={status}
/>
</div>
);
}
@ -155,7 +167,13 @@ export function EditableWONumberCell({
)}
<AddOnPill when={isAddOn} />
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
<CarriedOverCounterPill count={carriedOver} />
<CarriedOverCounterPill
count={carriedOver}
originalWeek={originalWeek}
originalDate={originalDate}
scheduledOn={scheduledOn}
status={status}
/>
</div>
);
}

View file

@ -1,5 +1,10 @@
import { RefreshCw } from "lucide-react";
import { Tooltip } from "@mui/material";
import {
formatCarriedOverHover,
resolveCarryOverSourceWeek,
shouldShowCarriedOverPill,
} from "@/domain/work-orders/utils/carry-over-display";
import { formatRescheduleHover } from "@/domain/work-orders/utils/reschedule-indicator";
const RESCHEDULE_PILL_STYLE = {
@ -46,17 +51,30 @@ export function RescheduleCounterPill({
);
}
export function CarriedOverCounterPill({ count }: { count: number }) {
if (count < 2) {
export function CarriedOverCounterPill({
count,
originalWeek,
originalDate,
scheduledOn,
status,
}: {
count: number;
originalWeek?: string;
originalDate?: string;
scheduledOn?: string;
status?: string;
}) {
if (!shouldShowCarriedOverPill(count, status)) {
return null;
}
const label = `Carried over ${count} times`;
const sourceWeek = resolveCarryOverSourceWeek({ originalWeek, originalDate, scheduledOn });
const hover = formatCarriedOverHover(count, sourceWeek);
return (
<Tooltip title="Carried over" arrow placement="top">
<Tooltip title={hover} arrow placement="top">
<span
aria-label={label}
aria-label={hover}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={MUTED_PILL_STYLE}
>

View file

@ -83,6 +83,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}

View file

@ -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 (
<span
role="button"
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
aria-label={woNumber ? `Reorder work order ${woNumber}` : "Reorder work order"}
className="flex items-center justify-center"
style={{
cursor: isDragging ? "grabbing" : "grab",
color: "var(--color-text-muted)",
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (disabled || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) {
return;
}
event.preventDefault();
event.stopPropagation();
onArrowReorder(event.key === "ArrowUp" ? -1 : 1);
}}
>
<GripVertical size={14} aria-hidden />
</span>
);
}

View file

@ -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()}
>
<span
className="flex items-center justify-center"
style={{
cursor: isCanceled ? "default" : isDragging ? "grabbing" : "grab",
color: "var(--color-text-muted)",
<WoTableRowReorderHandle
disabled={isCanceled}
isDragging={isDragging}
woNumber={row.woNumber}
onArrowReorder={(delta) => {
const overId = neighborIdForReorder(
allRows,
row,
delta,
(item) => !isWorkOrderFullyLocked(item.status),
);
if (overId == null) return;
handlers.onReorder(row.id, overId);
}}
>
<GripVertical size={14} />
</span>
/>
</td>
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
<FlagCell

View file

@ -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);
}

View file

@ -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,
};
}

View file

@ -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<typeof useWorkOrdersListFilters>;
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,
};
}

View file

@ -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<Record<string, Partial<WorkOrderTableRow>>>({});
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
@ -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;

View file

@ -0,0 +1,46 @@
import type { BoardRowOrderByDay } from "@/domain/work-orders/utils/board-row-order-storage";
import {
moveIdToDayOrder,
removeIdFromRowOrder,
saveBoardRowOrder,
transferIdToDestinationWeekOrder,
} from "@/domain/work-orders/utils/board-row-order-storage";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
export type ApplyBoardRowOrderOnScheduleChangeInput = {
weekMonday: string;
ownerId: string;
id: string;
scheduledOn: string;
previousDayGroup?: string;
};
export function applyBoardRowOrderOnScheduleChange(
orderByDay: BoardRowOrderByDay,
input: ApplyBoardRowOrderOnScheduleChangeInput,
): BoardRowOrderByDay {
const { weekMonday, ownerId, id, scheduledOn, previousDayGroup } = input;
const nextDayGroup = deriveDayFields(scheduledOn).dayGroup;
if (!previousDayGroup || previousDayGroup === nextDayGroup) {
return orderByDay;
}
const destinationWeekMonday = mondayOf(scheduledOn.slice(0, 10));
if (destinationWeekMonday !== weekMonday) {
transferIdToDestinationWeekOrder({
sourceWeekMonday: weekMonday,
destinationWeekMonday,
ownerId,
id,
nextDayGroup,
});
return removeIdFromRowOrder(orderByDay, id);
}
const next = moveIdToDayOrder(orderByDay, id, nextDayGroup);
if (next !== orderByDay) {
saveBoardRowOrder(weekMonday, next, ownerId);
}
return next;
}

View file

@ -2,7 +2,12 @@ export type BoardRowOrderByDay = Record<string, string[]>;
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<T extends { id: string | number; dayGroup: st
});
}
/** Same-day neighbor for keyboard reorder; null at list ends or if the row is missing. */
export function neighborIdForReorder<T extends { id: string | number; dayGroup: string }>(
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

View file

@ -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) };
}

View file

@ -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)) {

View file

@ -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<WorkOrderTableRow, "scheduledOn">,
row: Pick<WorkOrderTableRow, "scheduledOn" | "status" | "carriedOver">,
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)),
};
}

View file

@ -18,6 +18,20 @@ describe("CarriedOverCounterPill", () => {
expect(pill).toHaveTextContent("3");
});
it("includes the source week in the accessible name", () => {
renderWithProviders(
<CarriedOverCounterPill count={3} originalWeek="2026-05-04" status="Scheduled" />,
);
expect(screen.getByLabelText(/Source week: 5\/4–5\/8/i)).toBeInTheDocument();
});
it("hides after resolution even when the counter remains", () => {
const { container } = renderWithProviders(
<CarriedOverCounterPill count={3} originalWeek="2026-05-04" status="Completed" />,
);
expect(container).toBeEmptyDOMElement();
});
it("is distinguishable from the reschedule pill beside it", () => {
renderWithProviders(
<>

View file

@ -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: <T,>(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();
});
});

View file

@ -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> = {}): 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"]);
});
});

View file

@ -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> = {}): 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> = {}): 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(
<table>
<tbody>
<WoTableRow
row={row}
allRows={allRows}
users={[]}
sites={[]}
vendors={[]}
pmTypes={["HVAC"]}
isSelected={false}
isFlashing={false}
dragViz={{ dragId: null, overId: null }}
handlers={rowHandlers}
/>
</tbody>
</table>,
{ 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");
});
});

View file

@ -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]);
});
});

View file

@ -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({});

View file

@ -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);
});
});

View file

@ -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" });

View file

@ -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);
});
});