mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 02:43:12 +00:00
chore(work-orders): keep type/severity audit rules after merging origin/dev
This commit is contained in:
commit
f51f6bd493
42 changed files with 1980 additions and 252 deletions
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
|
|||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
|
||||
export function buildInlineDraftCreateInput(
|
||||
draft: WorkOrderTableDraft,
|
||||
|
|
@ -36,7 +37,11 @@ export function buildInlineDraftCreateInput(
|
|||
vendorNotes: "",
|
||||
apptStart: draft.apptStart,
|
||||
apptEnd: draft.apptEnd,
|
||||
status: draft.status || "Incomplete",
|
||||
status: resolveCreationStatus({
|
||||
status: draft.status || "Incomplete",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly,
|
||||
}),
|
||||
pocName: draft.pocName,
|
||||
pocPhone: draft.pocPhone,
|
||||
pocNotes: draft.pocNotes,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,19 @@ import { useState } from "react";
|
|||
import { AlertTriangle, Check } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
tableRowToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
BOARD_INLINE_STATUSES,
|
||||
PAST_DUE_BADGE_TOOLTIP,
|
||||
PAST_DUE_POPOVER_BODY,
|
||||
PAST_DUE_RESCHEDULE_CTA,
|
||||
getStatusOptionState,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||
|
||||
|
|
@ -24,6 +27,7 @@ type StatusCellProps = {
|
|||
onPastDueAttempt?: () => void;
|
||||
onRequestComplete?: () => void;
|
||||
readOnly?: boolean;
|
||||
statusOptions?: readonly WizardWOStatus[];
|
||||
};
|
||||
|
||||
function ReadOnlyStatusLabel({ status }: { status: string }) {
|
||||
|
|
@ -64,7 +68,7 @@ function PastDueStatusCell({
|
|||
onPastDueAttempt?.();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
||||
title={PAST_DUE_BADGE_TOOLTIP}
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
|
|
@ -97,7 +101,7 @@ function PastDueStatusCell({
|
|||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. Update Schedule On before the status can be updated.
|
||||
{PAST_DUE_POPOVER_BODY}
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -108,7 +112,7 @@ function PastDueStatusCell({
|
|||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Update Schedule On →
|
||||
{PAST_DUE_RESCHEDULE_CTA}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,6 +128,7 @@ export function StatusCell({
|
|||
onPastDueAttempt,
|
||||
onRequestComplete,
|
||||
readOnly = false,
|
||||
statusOptions = BOARD_INLINE_STATUSES,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const closabilityGaps = rowClosabilityGaps(wo);
|
||||
|
|
@ -162,22 +167,19 @@ export function StatusCell({
|
|||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||
slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }}
|
||||
>
|
||||
{ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
const missingTitle = isCompletedBlocked
|
||||
? formatClosabilityMissingTitle(closabilityGaps)
|
||||
: undefined;
|
||||
{statusOptions.map((status) => {
|
||||
const option = getStatusOptionState(wo, status, closabilityGaps);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
disabled={isCompletedBlocked}
|
||||
title={missingTitle}
|
||||
disabled={option.disabled}
|
||||
title={option.title}
|
||||
onClick={() => {
|
||||
if (isCompletedBlocked) {
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
if (status === "Completed" && wo.status !== "Completed") {
|
||||
|
|
@ -189,9 +191,9 @@ export function StatusCell({
|
|||
setAnchorEl(null);
|
||||
}}
|
||||
className={
|
||||
isCompletedBlocked
|
||||
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
option.disabled
|
||||
? "flex w-full cursor-not-allowed items-center justify-between gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
}
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<StatusCell
|
||||
wo={draftRow as never}
|
||||
statusOptions={CREATION_INLINE_STATUSES}
|
||||
onChangeStatus={(status) => onPatch({ status })}
|
||||
onOpenSlideOver={() => {}}
|
||||
onRequestComplete={onRequestComplete}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { withAutoScheduleStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
|
|
@ -146,7 +147,11 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
|
||||
onClear={
|
||||
hasWizardSchedule(draft)
|
||||
? () => onPatch(withAutoScheduleStatus(draft, clearSchedulePatch()))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Schedule On
|
||||
</WizLabelRow>
|
||||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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: <ColorDot color={WIZARD_STATUS_DOTS[status]} />,
|
||||
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={<ColorDot color={statusDotColor} />}
|
||||
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 }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WorkOrderCompleteDialog
|
||||
open={confirmComplete}
|
||||
isPending={false}
|
||||
onDialogClose={() => setConfirmComplete(false)}
|
||||
onKeepOpen={() => setConfirmComplete(false)}
|
||||
onConfirm={() => {
|
||||
onUpdate("status", "Completed");
|
||||
setConfirmComplete(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof workOrdersA
|
|||
);
|
||||
}
|
||||
|
||||
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
|
||||
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
|
||||
const hasDate = !!row.scheduledOn;
|
||||
const hasDispatcher = !!row.dispatcherId;
|
||||
if (hasDate && hasDispatcher && row.status === "Incomplete") {
|
||||
return { status: "Scheduled" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a schedule table patch. Clearing the schedule optimistically clears
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response.
|
||||
*/
|
||||
export function applySchedulePatch(
|
||||
scheduledOn: string,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -59,11 +59,19 @@ function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleConte
|
|||
return `Changed Severity to ${wrapAuditValue(labeled)}`;
|
||||
}
|
||||
|
||||
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
function tryStatusRule({
|
||||
fieldKey,
|
||||
actionKey,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
if (oldValue.text) {
|
||||
return `Changed status from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
return `Changed status to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
109
src/domain/work-orders/utils/carry-over-display.ts
Normal file
109
src/domain/work-orders/utils/carry-over-display.ts
Normal 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) };
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
|
|
@ -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<string>(["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<T extends ScheduleDateInput & { status?: string }>(
|
||||
current: T,
|
||||
schedulePatch: Partial<T> & ScheduleDateInput,
|
||||
): Partial<T> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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(<StatusCell wo={incompleteRow} onChangeStatus={vi.fn()} onOpenSlideOver={vi.fn()} />);
|
||||
|
||||
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(
|
||||
<StatusCell
|
||||
wo={{ ...incompleteRow, status: "Pending", scheduledOn: "" }}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<StatusCell
|
||||
wo={{ ...incompleteRow, status: "Incomplete" }}
|
||||
statusOptions={["Incomplete", "Pending", "Scheduled"]}
|
||||
onChangeStatus={vi.fn()}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(<StatusCell wo={pastDueRow} onChangeStatus={vi.fn()} onOpenSlideOver={vi.fn()} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Scheduled" }}
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||
vendors={[]}
|
||||
onUpdate={onUpdate}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<WizardStepVendorTime
|
||||
|
|
@ -51,10 +48,54 @@ describe("WizardStepVendorTime closability gate", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||
vendors={[]}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<WizardStepVendorTime
|
||||
draft={{
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}}
|
||||
vendors={[]}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
|
||||
expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { status: "En Route" });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: queryKeys.workOrders.all }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
|
|
|||
94
src/test/domain/work-orders/utils/carry-over-display.test.ts
Normal file
94
src/test/domain/work-orders/utils/carry-over-display.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue