mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 15:52:04 +00:00
Merge pull request #105 from Sea-Haven-Industries/feature/wo-uplift-pending-close-gate
fix(work-orders): unify closability and block Completed on pending uplift (SH-188)
This commit is contained in:
commit
08a32b71dd
9 changed files with 762 additions and 97 deletions
|
|
@ -1,108 +1,126 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { AlertTriangle, Check } from "lucide-react";
|
import { AlertTriangle, Check } from "lucide-react";
|
||||||
|
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ALL_WIZARD_STATUSES,
|
ALL_WIZARD_STATUSES,
|
||||||
type WizardWOStatus,
|
type WizardWOStatus,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
formatClosabilityMissingTitle,
|
||||||
|
getClosabilityGaps,
|
||||||
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
import { HoverChevron } from "./hover-chevron";
|
import { HoverChevron } from "./hover-chevron";
|
||||||
|
|
||||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||||
|
|
||||||
type StatusCellProps = {
|
type StatusCellProps = {
|
||||||
wo: WorkOrderTableRow;
|
wo: WorkOrderTableRow;
|
||||||
|
|
||||||
onChangeStatus: (status: WizardWOStatus) => void;
|
onChangeStatus: (status: WizardWOStatus) => void;
|
||||||
|
|
||||||
onOpenSlideOver: () => void;
|
onOpenSlideOver: () => void;
|
||||||
|
|
||||||
onPastDueAttempt?: () => void;
|
onPastDueAttempt?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function rowClosabilityGaps(wo: WorkOrderTableRow) {
|
||||||
|
return getClosabilityGaps({
|
||||||
|
dispatcherId: wo.dispatcherId,
|
||||||
|
dispatcherName: wo.dispatcherName,
|
||||||
|
company: wo.company,
|
||||||
|
tech: wo.tech,
|
||||||
|
completedDate: wo.completedDate ?? "",
|
||||||
|
woNumber: wo.woNumber,
|
||||||
|
pm: wo.pm,
|
||||||
|
docStatus: wo.docStatus,
|
||||||
|
mediaCount: wo.mediaCount,
|
||||||
|
type: wo.type,
|
||||||
|
upliftSummary: wo.upliftSummary,
|
||||||
|
hasPendingUplift: wo.hasPendingUplift,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function PastDueStatusCell({
|
||||||
|
onOpenSlideOver,
|
||||||
|
onPastDueAttempt,
|
||||||
|
}: {
|
||||||
|
onOpenSlideOver: () => void;
|
||||||
|
onPastDueAttempt?: () => void;
|
||||||
|
}) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPastDueAttempt?.();
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
}}
|
||||||
|
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
||||||
|
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||||
|
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
||||||
|
Past Due
|
||||||
|
<HoverChevron />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
slotProps={{
|
||||||
|
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2 p-3"
|
||||||
|
style={{ background: "var(--status-pending-bg)" }}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.375,
|
||||||
|
color: "var(--status-pending-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This work order is past due. Update Schedule On before the status can be updated.
|
||||||
|
</Text>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenSlideOver();
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
Update Schedule On →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusCell({
|
export function StatusCell({
|
||||||
wo,
|
wo,
|
||||||
|
|
||||||
onChangeStatus,
|
onChangeStatus,
|
||||||
|
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
|
|
||||||
onPastDueAttempt,
|
onPastDueAttempt,
|
||||||
}: StatusCellProps) {
|
}: StatusCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const closabilityGaps = rowClosabilityGaps(wo);
|
||||||
|
|
||||||
if (wo.isPastDue) {
|
if (wo.isPastDue) {
|
||||||
return (
|
return (
|
||||||
<>
|
<PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
onPastDueAttempt?.();
|
|
||||||
|
|
||||||
setAnchorEl(e.currentTarget);
|
|
||||||
}}
|
|
||||||
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
|
||||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
|
||||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
|
||||||
>
|
|
||||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
|
||||||
Past Due
|
|
||||||
<HoverChevron />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
open={Boolean(anchorEl)}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
onClose={() => setAnchorEl(null)}
|
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
slotProps={{
|
|
||||||
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-start gap-2 p-3"
|
|
||||||
style={{ background: "var(--status-pending-bg)" }}
|
|
||||||
>
|
|
||||||
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 1.375,
|
|
||||||
color: "var(--status-pending-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
This work order is past due. Update Schedule On before the status can be updated.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
onOpenSlideOver();
|
|
||||||
|
|
||||||
setAnchorEl(null);
|
|
||||||
}}
|
|
||||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
|
||||||
>
|
|
||||||
Update Schedule On →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +130,6 @@ export function StatusCell({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||||
|
|
@ -122,7 +139,6 @@ export function StatusCell({
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -134,24 +150,38 @@ export function StatusCell({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||||
>
|
>
|
||||||
{ALL_WIZARD_STATUSES.map((status) => (
|
{ALL_WIZARD_STATUSES.map((status) => {
|
||||||
<button
|
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||||
key={status}
|
const missingTitle = isCompletedBlocked
|
||||||
type="button"
|
? formatClosabilityMissingTitle(closabilityGaps)
|
||||||
onClick={() => {
|
: undefined;
|
||||||
onChangeStatus(status);
|
|
||||||
|
|
||||||
setAnchorEl(null);
|
return (
|
||||||
}}
|
<button
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
key={status}
|
||||||
>
|
type="button"
|
||||||
<WoTableStatusLabel status={status} />
|
disabled={isCompletedBlocked}
|
||||||
|
title={missingTitle}
|
||||||
{wo.status === status && (
|
onClick={() => {
|
||||||
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
if (isCompletedBlocked) {
|
||||||
)}
|
return;
|
||||||
</button>
|
}
|
||||||
))}
|
onChangeStatus(status);
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
isCompletedBlocked
|
||||||
|
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
|
||||||
|
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<WoTableStatusLabel status={status} />
|
||||||
|
{Boolean(wo.status === status) && (
|
||||||
|
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Popover>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import type {
|
import type {
|
||||||
CompletionDocStatus,
|
CompletionDocStatus,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
|
WorkOrderUpliftSummary,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
@ -215,6 +216,55 @@ function readFlagColor(record: Record<string, unknown>): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readUpliftSummary(item: Record<string, unknown>): WorkOrderUpliftSummary | undefined {
|
||||||
|
const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
|
||||||
|
const hasNested = Object.keys(nested).length > 0;
|
||||||
|
const source = hasNested ? nested : item;
|
||||||
|
|
||||||
|
// Board/search/detail contract (SH-188): pendingUpliftCount is authoritative when present.
|
||||||
|
const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
|
||||||
|
const nestedCount = readNumber(source, "pendingCount", "PendingCount");
|
||||||
|
const pendingCount = nestedCount ?? boardCount ?? 0;
|
||||||
|
const hasUplift =
|
||||||
|
readBool(source, "hasUplift", "HasUplift") ||
|
||||||
|
readBool(item, "hasUplift", "HasUplift") ||
|
||||||
|
pendingCount > 0;
|
||||||
|
|
||||||
|
// No nested summary and no board count → omit (legacy payloads without the contract field).
|
||||||
|
if (!hasNested && boardCount == null && !hasUplift) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: WorkOrderUpliftSummary = {
|
||||||
|
hasUplift: hasUplift || pendingCount > 0,
|
||||||
|
pendingCount,
|
||||||
|
};
|
||||||
|
const primaryStatus =
|
||||||
|
readString(nested, "primaryStatus", "PrimaryStatus") ||
|
||||||
|
readString(item, "primaryStatus", "PrimaryStatus");
|
||||||
|
if (primaryStatus) {
|
||||||
|
summary.primaryStatus = primaryStatus;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHasPendingUplift(
|
||||||
|
item: Record<string, unknown>,
|
||||||
|
upliftSummary: WorkOrderUpliftSummary | undefined,
|
||||||
|
): boolean | undefined {
|
||||||
|
if ("hasPendingUplift" in item || "HasPendingUplift" in item) {
|
||||||
|
return Boolean(item.hasPendingUplift ?? item.HasPendingUplift);
|
||||||
|
}
|
||||||
|
const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount");
|
||||||
|
if (boardCount != null) {
|
||||||
|
return boardCount > 0;
|
||||||
|
}
|
||||||
|
if (upliftSummary != null) {
|
||||||
|
return upliftSummary.pendingCount > 0;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const scheduledOn = toDateInputValue(
|
const scheduledOn = toDateInputValue(
|
||||||
|
|
@ -269,6 +319,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
const locationIdRaw = item.locationId ?? item.LocationId;
|
const locationIdRaw = item.locationId ?? item.LocationId;
|
||||||
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
||||||
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
||||||
|
const upliftSummary = readUpliftSummary(item);
|
||||||
|
const hasPendingUplift = readHasPendingUplift(item, upliftSummary);
|
||||||
|
|
||||||
const row: WorkOrderTableRow = {
|
const row: WorkOrderTableRow = {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
|
|
@ -279,7 +331,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
dispatcherId,
|
dispatcherId,
|
||||||
dispatcherName,
|
dispatcherName,
|
||||||
scheduledOn,
|
scheduledOn,
|
||||||
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
|
completedDate: toDateInputValue(
|
||||||
|
readString(item, "completedDate", "CompletedDate", "completedOn", "CompletedOn"),
|
||||||
|
),
|
||||||
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
||||||
dayGroup,
|
dayGroup,
|
||||||
dayLabel,
|
dayLabel,
|
||||||
|
|
@ -316,6 +370,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
||||||
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
||||||
flagColor: readFlagColor(item),
|
flagColor: readFlagColor(item),
|
||||||
|
upliftSummary,
|
||||||
|
hasPendingUplift,
|
||||||
};
|
};
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,11 @@ export interface WorkOrderBoardRowApi {
|
||||||
primaryDispatchId?: number | null;
|
primaryDispatchId?: number | null;
|
||||||
rowVersion?: string | null;
|
rowVersion?: string | null;
|
||||||
dispatchRowVersion?: string | null;
|
dispatchRowVersion?: string | null;
|
||||||
|
/**
|
||||||
|
* Authoritative pending-uplift count across all dispatches for this WO (SH-188 / BE board contract).
|
||||||
|
* Drives the Completed closability gate.
|
||||||
|
*/
|
||||||
|
pendingUpliftCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderBoardCountsApi {
|
export interface WorkOrderBoardCountsApi {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||||
|
|
||||||
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
|
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
export type WorkOrderUpliftSummary = {
|
||||||
|
hasUplift: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
primaryStatus?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface WorkOrderTableRow {
|
export interface WorkOrderTableRow {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
woNumber: string;
|
woNumber: string;
|
||||||
|
|
@ -15,7 +21,7 @@ export interface WorkOrderTableRow {
|
||||||
dispatcherId: string;
|
dispatcherId: string;
|
||||||
dispatcherName: string;
|
dispatcherName: string;
|
||||||
scheduledOn: string;
|
scheduledOn: string;
|
||||||
/** Work completed date (API CompletedDate); distinct from scheduledOn. */
|
/** Work completed date (API CompletedDate); used by closability / COMPLETED DATE column. */
|
||||||
completedDate: string;
|
completedDate: string;
|
||||||
targetWeek: string;
|
targetWeek: string;
|
||||||
dayGroup: string;
|
dayGroup: string;
|
||||||
|
|
@ -59,6 +65,8 @@ export interface WorkOrderTableRow {
|
||||||
dispatchRowVersion: string;
|
dispatchRowVersion: string;
|
||||||
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
||||||
flagColor: string | null;
|
flagColor: string | null;
|
||||||
|
upliftSummary?: WorkOrderUpliftSummary | null;
|
||||||
|
hasPendingUplift?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderTableDraft {
|
export interface WorkOrderTableDraft {
|
||||||
|
|
|
||||||
148
src/domain/work-orders/utils/wo-closability.ts
Normal file
148
src/domain/work-orders/utils/wo-closability.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import type {
|
||||||
|
CompletionDocStatus,
|
||||||
|
WorkOrderUpliftSummary,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */
|
||||||
|
export type WoClosabilityInput = {
|
||||||
|
dispatcherId: string;
|
||||||
|
dispatcherName: string;
|
||||||
|
/** Company / vendor assignment — required for Completed (SH-183). */
|
||||||
|
company: string;
|
||||||
|
/** Technician is optional for closability; modeled separately from company. */
|
||||||
|
tech: string;
|
||||||
|
/** Work completed date (API CompletedDate). Empty until set. */
|
||||||
|
completedDate: string;
|
||||||
|
woNumber: string;
|
||||||
|
pm: string;
|
||||||
|
docStatus: CompletionDocStatus;
|
||||||
|
mediaCount: number;
|
||||||
|
type: WOType | "";
|
||||||
|
upliftSummary?: WorkOrderUpliftSummary | null;
|
||||||
|
hasPendingUplift?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PENDING_UPLIFT_GAP = "Pending uplift approval";
|
||||||
|
|
||||||
|
/** SH-188 tooltip when Completed is blocked solely by a pending uplift. */
|
||||||
|
export const UPLIFT_PENDING_COMPLETED_TITLE =
|
||||||
|
"This work order has an uplift pending approval and cannot be completed.";
|
||||||
|
|
||||||
|
/** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */
|
||||||
|
export function isRealWoNumber(woNumber: string): boolean {
|
||||||
|
const trimmed = woNumber.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !/^SH/i.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPendingUplift(
|
||||||
|
row: Pick<WoClosabilityInput, "upliftSummary" | "hasPendingUplift">,
|
||||||
|
): boolean {
|
||||||
|
if (row.hasPendingUplift === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (row.upliftSummary?.pendingCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatcherName">): boolean {
|
||||||
|
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompany(company: string): boolean {
|
||||||
|
return Boolean(company.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasService(pm: string): boolean {
|
||||||
|
return Boolean(pm.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompletionDocument(docStatus: CompletionDocStatus): boolean {
|
||||||
|
return docStatus === "Yes" || docStatus === "NN";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gaps that block Completed, in design hierarchy:
|
||||||
|
* fields → uplift pending → completion document / photos.
|
||||||
|
* (Scheduled-date gate is a separate design P1 — not in this module yet.)
|
||||||
|
*/
|
||||||
|
export function getClosabilityGaps(row: WoClosabilityInput): string[] {
|
||||||
|
const gaps: string[] = [];
|
||||||
|
|
||||||
|
if (!hasDispatcher(row)) {
|
||||||
|
gaps.push("Dispatcher");
|
||||||
|
}
|
||||||
|
if (!hasCompany(row.company)) {
|
||||||
|
gaps.push("Company");
|
||||||
|
}
|
||||||
|
if (!row.completedDate.trim()) {
|
||||||
|
gaps.push("Completed Date");
|
||||||
|
}
|
||||||
|
if (!isRealWoNumber(row.woNumber)) {
|
||||||
|
gaps.push("WO number");
|
||||||
|
}
|
||||||
|
if (!hasService(row.pm)) {
|
||||||
|
gaps.push("Service");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authoritative pending uplift (board pendingUpliftCount / hasPendingUplift) blocks completion.
|
||||||
|
// Missing uplift fields mean no pending evidence until the board contract supplies the count.
|
||||||
|
if (hasPendingUplift(row)) {
|
||||||
|
gaps.push(PENDING_UPLIFT_GAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === "PM" || row.type === "Overdue") {
|
||||||
|
if (!hasCompletionDocument(row.docStatus)) {
|
||||||
|
gaps.push("Completion document");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === "Emergency" || row.type === "Reactive") {
|
||||||
|
if (row.mediaCount < 1) {
|
||||||
|
gaps.push("At least 1 photo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatClosabilityMissingTitle(gaps: string[]): string {
|
||||||
|
if (gaps.includes(PENDING_UPLIFT_GAP)) {
|
||||||
|
return UPLIFT_PENDING_COMPLETED_TITLE;
|
||||||
|
}
|
||||||
|
return `Missing: ${gaps.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompDocGateResult = { allowed: true } | { allowed: false; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate for completion-document generation. CompDocDialog (not yet on `dev`) should call this
|
||||||
|
* before opening generate flow and surface `reason` when blocked.
|
||||||
|
*
|
||||||
|
* Intentionally omits the "Completion document" closability gap — generate is how that gap is filled.
|
||||||
|
*/
|
||||||
|
export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult {
|
||||||
|
const gaps = getClosabilityGaps(row).filter((gap) => gap !== "Completion document");
|
||||||
|
if (gaps.length > 0) {
|
||||||
|
return { allowed: false, reason: formatClosabilityMissingTitle(gaps) };
|
||||||
|
}
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map create-wizard draft onto the shared closability contract. */
|
||||||
|
export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput {
|
||||||
|
return {
|
||||||
|
dispatcherId: draft.assigneeId,
|
||||||
|
dispatcherName: draft.assigneeName,
|
||||||
|
company: draft.vendorName,
|
||||||
|
tech: "",
|
||||||
|
completedDate: "",
|
||||||
|
woNumber: draft.woNumber,
|
||||||
|
pm: draft.pm,
|
||||||
|
docStatus: "",
|
||||||
|
mediaCount: 0,
|
||||||
|
type: draft.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
woNumber: "1001",
|
||||||
|
site: "BK5",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Alex",
|
||||||
|
scheduledOn: "2026-07-15",
|
||||||
|
completedDate: "2026-07-20",
|
||||||
|
targetWeek: "2026-07-15",
|
||||||
|
dayGroup: "07/15",
|
||||||
|
dayLabel: "Tue",
|
||||||
|
location: "Lobby",
|
||||||
|
locationId: "loc-1",
|
||||||
|
pm: "HVAC",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "7",
|
||||||
|
company: "Acme",
|
||||||
|
tech: "Sam",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "In Progress",
|
||||||
|
docStatus: "Yes",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
isAddOn: false,
|
||||||
|
mediaCount: 1,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StatusCell pending uplift closability", () => {
|
||||||
|
it("disables Completed with tooltip when uplift is pending", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({ hasPendingUplift: true })}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
|
||||||
|
const completed = screen.getByRole("button", { name: /completed/i });
|
||||||
|
expect(completed).toBeDisabled();
|
||||||
|
expect(completed).toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
"This work order has an uplift pending approval and cannot be completed.",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(completed);
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Completed with tooltip when board pendingUpliftCount maps to pending", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({
|
||||||
|
hasPendingUplift: true,
|
||||||
|
upliftSummary: { hasUplift: true, pendingCount: 1 },
|
||||||
|
})}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
|
||||||
|
const completed = screen.getByRole("button", { name: /completed/i });
|
||||||
|
expect(completed).toBeDisabled();
|
||||||
|
expect(completed).toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
"This work order has an uplift pending approval and cannot be completed.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Completed when no pending uplift", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell wo={makeRow()} onChangeStatus={onChangeStatus} onOpenSlideOver={vi.fn()} />,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
|
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Completed when company is missing (SH-183)", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({ company: "" })}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
|
||||||
|
const completed = screen.getByRole("button", { name: /completed/i });
|
||||||
|
expect(completed).toBeDisabled();
|
||||||
|
expect(completed).toHaveAttribute("title", "Missing: Company");
|
||||||
|
fireEvent.click(completed);
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Completed when technician is empty if company is set (SH-183)", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({ tech: "" })}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
|
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -174,4 +174,64 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
|
||||||
expect(mapWorkOrderTableRow({ id: 1, flagColor: "" }).flagColor).toBeNull();
|
expect(mapWorkOrderTableRow({ id: 1, flagColor: "" }).flagColor).toBeNull();
|
||||||
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
|
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps uplift summary and pending flag from API payload", () => {
|
||||||
|
const row = mapWorkOrderTableRow({
|
||||||
|
id: 303,
|
||||||
|
upliftSummary: {
|
||||||
|
hasUplift: true,
|
||||||
|
pendingCount: 2,
|
||||||
|
primaryStatus: "pending",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(row.upliftSummary).toEqual({
|
||||||
|
hasUplift: true,
|
||||||
|
pendingCount: 2,
|
||||||
|
primaryStatus: "pending",
|
||||||
|
});
|
||||||
|
expect(row.hasPendingUplift).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps board pendingUpliftCount into hasPendingUplift (SH-188 contract)", () => {
|
||||||
|
const pending = mapWorkOrderTableRow({
|
||||||
|
id: 305,
|
||||||
|
pendingUpliftCount: 1,
|
||||||
|
});
|
||||||
|
expect(pending.hasPendingUplift).toBe(true);
|
||||||
|
expect(pending.upliftSummary).toEqual({ hasUplift: true, pendingCount: 1 });
|
||||||
|
|
||||||
|
const clear = mapWorkOrderTableRow({
|
||||||
|
id: 306,
|
||||||
|
PendingUpliftCount: 0,
|
||||||
|
});
|
||||||
|
expect(clear.hasPendingUplift).toBe(false);
|
||||||
|
expect(clear.upliftSummary).toEqual({ hasUplift: false, pendingCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not copy work-order status into uplift primaryStatus on flat board rows", () => {
|
||||||
|
const row = mapWorkOrderTableRow({
|
||||||
|
id: 308,
|
||||||
|
pendingUpliftCount: 1,
|
||||||
|
status: "In Progress",
|
||||||
|
Status: "In Progress",
|
||||||
|
});
|
||||||
|
expect(row.upliftSummary?.primaryStatus).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves hasPendingUplift undefined when board count is absent", () => {
|
||||||
|
const row = mapWorkOrderTableRow({ id: 307 });
|
||||||
|
expect(row.hasPendingUplift).toBeUndefined();
|
||||||
|
expect(row.upliftSummary).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps hasPendingUplift from top-level API flag", () => {
|
||||||
|
const row = mapWorkOrderTableRow({
|
||||||
|
id: 304,
|
||||||
|
hasPendingUplift: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(row.hasPendingUplift).toBe(true);
|
||||||
|
expect(row.upliftSummary).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
159
src/test/domain/work-orders/utils/wo-closability.test.ts
Normal file
159
src/test/domain/work-orders/utils/wo-closability.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
import {
|
||||||
|
PENDING_UPLIFT_GAP,
|
||||||
|
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||||
|
canGenerateCompDoc,
|
||||||
|
formatClosabilityMissingTitle,
|
||||||
|
getClosabilityGaps,
|
||||||
|
hasPendingUplift,
|
||||||
|
isRealWoNumber,
|
||||||
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
|
||||||
|
function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityInput {
|
||||||
|
return {
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Alex",
|
||||||
|
company: "Acme HVAC",
|
||||||
|
tech: "Sam",
|
||||||
|
completedDate: "2026-07-01",
|
||||||
|
woNumber: "1001",
|
||||||
|
pm: "HVAC",
|
||||||
|
docStatus: "Yes",
|
||||||
|
mediaCount: 1,
|
||||||
|
type: "PM",
|
||||||
|
hasPendingUplift: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isRealWoNumber", () => {
|
||||||
|
it("rejects empty and SH-prefixed provisional numbers", () => {
|
||||||
|
expect(isRealWoNumber("")).toBe(false);
|
||||||
|
expect(isRealWoNumber("SH-9")).toBe(false);
|
||||||
|
expect(isRealWoNumber("sh123")).toBe(false);
|
||||||
|
expect(isRealWoNumber("98765")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasPendingUplift", () => {
|
||||||
|
it("is true when hasPendingUplift flag is set", () => {
|
||||||
|
expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when upliftSummary.pendingCount is positive", () => {
|
||||||
|
expect(
|
||||||
|
hasPendingUplift(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 2 } })),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when uplift is explicitly not pending", () => {
|
||||||
|
expect(hasPendingUplift(makeInput())).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getClosabilityGaps", () => {
|
||||||
|
it("returns empty when all gates are satisfied", () => {
|
||||||
|
expect(getClosabilityGaps(makeInput())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows company without technician (SH-183)", () => {
|
||||||
|
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
|
||||||
|
expect(gaps).toEqual([]);
|
||||||
|
expect(gaps).not.toContain("Technician");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Completed when company is missing even if technician is assigned", () => {
|
||||||
|
expect(getClosabilityGaps(makeInput({ company: "", tech: "Sam" }))).toEqual(["Company"]);
|
||||||
|
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists field gaps before uplift and document gaps", () => {
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps(
|
||||||
|
makeInput({
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
company: " ",
|
||||||
|
tech: " ",
|
||||||
|
completedDate: "",
|
||||||
|
woNumber: "SH-1",
|
||||||
|
pm: "",
|
||||||
|
hasPendingUplift: true,
|
||||||
|
docStatus: "No",
|
||||||
|
type: "PM",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"Dispatcher",
|
||||||
|
"Company",
|
||||||
|
"Completed Date",
|
||||||
|
"WO number",
|
||||||
|
"Service",
|
||||||
|
PENDING_UPLIFT_GAP,
|
||||||
|
"Completion document",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Completed when uplift is pending via summary", () => {
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 1 } })),
|
||||||
|
).toEqual([PENDING_UPLIFT_GAP]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Completed when hasPendingUplift is true", () => {
|
||||||
|
expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Completed when uplift status fields are absent (no pending evidence)", () => {
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a photo for Emergency / Reactive", () => {
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })),
|
||||||
|
).toEqual(["At least 1 photo"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatClosabilityMissingTitle", () => {
|
||||||
|
it("uses design Completed title when only uplift is missing", () => {
|
||||||
|
expect(formatClosabilityMissingTitle([PENDING_UPLIFT_GAP])).toBe(
|
||||||
|
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses design Completed title when pending uplift is among mixed gaps", () => {
|
||||||
|
expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe(
|
||||||
|
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins multiple non-uplift gaps with Missing prefix", () => {
|
||||||
|
expect(formatClosabilityMissingTitle(["Dispatcher", "Company"])).toBe(
|
||||||
|
"Missing: Dispatcher, Company",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canGenerateCompDoc", () => {
|
||||||
|
it("allows generation when no closability gaps", () => {
|
||||||
|
expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows generation when only the completion document is missing", () => {
|
||||||
|
expect(canGenerateCompDoc(makeInput({ docStatus: "No" }))).toEqual({ allowed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks generation with design reason when only pending uplift exists", () => {
|
||||||
|
expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({
|
||||||
|
allowed: false,
|
||||||
|
reason: UPLIFT_PENDING_COMPLETED_TITLE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
tmp/pr-descriptions/pr4-uplift-pending-close-gate.md
Normal file
39
tmp/pr-descriptions/pr4-uplift-pending-close-gate.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# PR4 — Pending uplift close gate (+ SH-114 closability union)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188).
|
||||||
|
|
||||||
|
- Unifies `wo-closability.ts` / `getClosabilityGaps` with hierarchy: **fields → uplift pending → completion document / photos**.
|
||||||
|
- Field gates: dispatcher, technician, completed date, real WO# (non-SH), service; then PM/Overdue completion doc; Emergency/Reactive ≥1 photo.
|
||||||
|
- Pending uplift via `hasPendingUplift` or `upliftSummary.pendingCount`.
|
||||||
|
- Completed tooltip uses design copy when the **only** gap is pending uplift.
|
||||||
|
- Maps optional `completedDate` on board rows for the Completed Date gate.
|
||||||
|
|
||||||
|
## Jira
|
||||||
|
|
||||||
|
- [SH-188](https://luby-us.atlassian.net/browse/SH-188) — pending uplift close gate
|
||||||
|
- Related: [SH-114](https://luby-us.atlassian.net/browse/SH-114) — closability field matrix (pulled into this branch so sister merges do not wipe gates)
|
||||||
|
|
||||||
|
## Comp Doc Generate
|
||||||
|
|
||||||
|
`CompDocDialog` is **not** mounted on `dev` yet. This PR exports `canGenerateCompDoc(row)` so the completion-doc slice can block generate/open with the same reason string. No UI wiring in this PR.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Full uplift CRUD (PR3)
|
||||||
|
- Advanced filter facets for uplift
|
||||||
|
- Scheduled-requires-date gate (design P1)
|
||||||
|
- COL_HELP tooltips (P1)
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [ ] Row with all fields ok + `hasPendingUplift: true` — Completed disabled; design tooltip about pending uplift.
|
||||||
|
- [ ] Row missing dispatcher / completed date — Completed disabled; `Missing: …` tooltip.
|
||||||
|
- [ ] Row fully closable — Completed selectable.
|
||||||
|
- [ ] `canGenerateCompDoc` blocked when gaps exist.
|
||||||
|
- [ ] Board API `completedDate` / `upliftSummary.pendingCount` map through to the gate.
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Merge **before** or instead of stacking raw SH-114 alone with an uplift-only closability overwrite.
|
||||||
Loading…
Add table
Reference in a new issue