diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index b5da6165..9048bad4 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -43,6 +43,9 @@ export const API_PATHS = { `workorders/${id}/media/${mediaId}/content`, completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, uplifts: (id: string | number) => `workorders/${id}/uplifts`, + upliftEvidence: (id: string | number) => `workorders/${id}/uplift-evidence`, + upliftEvidenceStatus: (workOrderId: string | number, documentId: string | number) => + `workorders/${workOrderId}/uplift-evidence/${documentId}`, upliftCancel: (workOrderId: string | number, upliftId: string | number) => `workorders/${workOrderId}/uplifts/${upliftId}/cancel`, upliftRevoke: (workOrderId: string | number, upliftId: string | number) => diff --git a/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx index eb491726..6438a65b 100644 --- a/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx +++ b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx @@ -27,7 +27,7 @@ function formatDays(value: number): string { return (Math.round(value * 10) / 10).toLocaleString(); } -// SH-347: colour the completion rate green at 90+, amber 70–89, red below 70. +// why: SH-347: colour the completion rate green at 90+, amber 70–89, red below 70. function completionRateColor(rate: number): string { if (rate >= 90) return "success.main"; if (rate >= 70) return "warning.main"; @@ -42,12 +42,10 @@ function DispatcherNameCell({ range: DashboardRangeKey; }) { const navigate = useNavigate(); - // A listed dispatcher is one with work in range; the drill-down filters Work - // Orders by dispatcher + range and never uses the completion rate, so gate on - // identity rather than the rate. (A dispatcher with assigned work but a 0% - // rate was previously inert here while drillable from Workload.) The gate stays - // on identity rather than `assignedCount > 0` so a legacy response without the - // SH-347 count field (assignedCount → 0) keeps its rows drillable. + // why: A listed dispatcher is one with work in range; the drill-down filters Work Orders by dispatcher + // + range and never uses the completion rate, so gate on identity rather than the rate. (A + // dispatcher with assigned work but a 0% rate was previously inert here while drillable from + // Workload.) The gate stays on identity rather than `assignedCount > 0` so a legacy response without the SH-347 count field (assignedCount → 0) keeps its rows drillable. const drillable = Boolean(row.dispatcherId); if (!drillable) { diff --git a/src/app/(protected)/dashboard/_components/status-distribution-card.tsx b/src/app/(protected)/dashboard/_components/status-distribution-card.tsx index 956735eb..2a9b17bc 100644 --- a/src/app/(protected)/dashboard/_components/status-distribution-card.tsx +++ b/src/app/(protected)/dashboard/_components/status-distribution-card.tsx @@ -43,8 +43,8 @@ export function StatusDistributionCard({ label: row.status, count: row.count, }))} - // Lifecycle labels the board can filter on are drillable; the "Unknown" - // bucket (unparseable statuses) has no board filter, so leave it inert. + // why: Lifecycle labels the board can filter on are drillable; the "Unknown" bucket (unparseable + // statuses) has no board filter, so leave it inert. isSelectable={(row) => DRILLABLE_STATUSES.has(row.id)} onSelectRow={(row) => navigate(workOrderDrilldownUrl(workOrderStatusDrilldownSearch(row.id, range))) diff --git a/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts b/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts index 868b8d05..c1c993df 100644 --- a/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts +++ b/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts @@ -40,9 +40,9 @@ export function useDashboardScope(): DashboardScopeState { const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null; - // A range or scope change re-scopes both dispatcher tables, so their page must - // reset — otherwise a switch while on page 3 lands on an out-of-range empty - // page that reads as a false "no dispatchers" state. + // why: A range or scope change re-scopes both dispatcher tables, so their page must reset — otherwise a + // switch while on page 3 lands on an out-of-range empty page that reads as a false "no + // dispatchers" state. const resetPages = () => { setWorkloadPage(1); setPerformancePage(1); @@ -64,8 +64,8 @@ export function useDashboardScope(): DashboardScopeState { resetPages(); }; - // A Dispatcher's forced own-WOs scope never triggers the reset affordance (only - // an active date range can, for that role); a Scheduler/Admin's non-All scope does. + // why: A Dispatcher's forced own-WOs scope never triggers the reset affordance (only an active date + // range can, for that role); a Scheduler/Admin's non-All scope does. const scopeIsFiltered = canViewAll && scope.mode !== "all"; const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered; diff --git a/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx b/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx index 40c53e0d..af6742b7 100644 --- a/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx +++ b/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx @@ -28,9 +28,8 @@ export function WorkOrdersByRegionCard({ }: WorkOrdersByRegionCardProps) { const navigate = useNavigate(); - // SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet - // confirmed with the client ("do not implement until confirmed"), so only the - // four canonical zones are shown. + // why: SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet confirmed with the client + // ("do not implement until confirmed"), so only the four canonical zones are shown. const zones = rows.filter((row) => row.region !== UNMAPPED_REGION_LABEL); return ( diff --git a/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts b/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts index 263d40e0..44875096 100644 --- a/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts +++ b/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts @@ -13,8 +13,8 @@ export function openUpliftEvidence(row: UpliftQueueItem): void { } export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void { - // Open the tab inside the click handler; a tab opened after the fetch resolves - // is treated as an unsolicited popup and blocked. + // why: Open the tab inside the click handler; a tab opened after the fetch resolves is treated as an + // unsolicited popup and blocked. const tab = window.open("about:blank", "_blank"); if (tab != null) { tab.opener = null; diff --git a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx index f31bafe3..5e165fbb 100644 --- a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx @@ -321,8 +321,8 @@ function RevokeModalAction({ } function useExposureBreakdown(item: UpliftQueueItem) { - // Permission failures (e.g. 403 for account-scoped users) are handled quietly: - // the breakdown falls back to "Unavailable" instead of toasting. + // why: Permission failures (e.g. 403 for account-scoped users) are handled quietly: the breakdown falls + // back to "Unavailable" instead of toasting. const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, { suppressErrorToast: true, }); diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 0beb87fe..54e7cbf8 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -131,7 +131,7 @@ export function useVendorRosterForm({ ); const { data: facets, isLoading: facetsLoading } = useVendorFacets(); const save = useSaveVendorCompanyRoster(); - // Area assignment is Admin-only; the API enforces it and the field mirrors it. + // why: Area assignment is Admin-only; the API enforces it and the field mirrors it. const canAssignArea = isAdminUser(useContext(AuthContext)?.user?.userRoles); const [conflict, setConflict] = useState(null); diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index f0893e32..2e6059d6 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -40,8 +40,8 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo city: roster.city, state: roster.state, zip: roster.zip, - // The deprecated stored Maps URL is intentionally not seeded: the form has - // no input for it, and carrying it would re-emit a legacy value on write. + // why: The deprecated stored Maps URL is intentionally not seeded: the form has no input for it, and + // carrying it would re-emit a legacy value on write. notes: roster.notes, areaId: roster.areaId, technicians: roster.technicians.map((technician) => ({ diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 2a69b204..a907feae 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -119,9 +119,8 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - // SH-271 replaced the separate "Open in Google Maps" row: the Street Address - // itself is the link now, and the location is derived from the structured - // parts rather than a stored URL. + // why: SH-271 replaced the separate "Open in Google Maps" row: the Street Address itself is the link + // now, and the location is derived from the structured parts rather than a stored URL. const address = { street: roster.address, city: roster.city, state: roster.state }; const mapsUrl = mapsSearchUrl(address); diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index d5094cf0..26aa87a8 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -167,9 +167,9 @@ function CompanyFields({ value={streetField.value ?? ""} onInputChange={streetField.onChange} onSelect={(parts) => { - // One pick fills all three. City and State are written through - // their own controllers so react-hook-form marks them dirty and - // revalidates — a plain assignment would be discarded on save. + // why: One pick fills all three. City and State are written through their own controllers so + // react-hook-form marks them dirty and revalidates — a plain assignment would be discarded on + // save. streetField.onChange(parts.street); cityField.onChange(parts.city); stateField.onChange(normalizeStateCode(parts.state) ?? parts.state); diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx index 6fe91dd6..bf38ea11 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -98,9 +98,9 @@ export function CompDocDialog({ const rowVersion = boardDetail?.info.rowVersion || row.rowVersion; const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status); - // The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in - // one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up - // Generate patch would carry the pre-upload version and 409 after a successful upload. + // why: The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in one + // transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up Generate + // patch would carry the pre-upload version and 409 after a successful upload. const upload = useCompDocPdfUpload({ workOrderId: row.id, rowVersion, diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx index 3d6937c1..c06f84af 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx @@ -175,7 +175,7 @@ export function SlideOverCommentsTab({ ); const firstHighlightedId = comments.find((comment) => highlightedIds.has(String(comment.id)))?.id; const firstHighlightedRef = useRef(null); - // Bring the first comment a notification pointed at into view whenever the target changes — the + // why: Bring the first comment a notification pointed at into view whenever the target changes — the // tab may already be mounted when a later notification re-opens this same work order. useEffect(() => { const node = firstHighlightedRef.current; diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx new file mode 100644 index 00000000..62234e49 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx @@ -0,0 +1,187 @@ +import { useState, type ReactNode } from "react"; +import { CircularProgress } from "@mui/material"; +import { AlertTriangle, Ban } from "lucide-react"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { Text } from "@/components/ui/text"; +import { CancelUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog"; +import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog"; +import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form"; +import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift"; +import { + useCancelWorkOrderUplift, + useCreateWorkOrderUplift, + useRevokeWorkOrderUplift, + useWorkOrderUplifts, +} from "@/domain/work-orders/use-cases/use-work-order-uplifts"; +import { + formatUpliftMoney, + getRemainingUpliftAllowance, + hasOpenWorkOrderUplift, + isWorkOrderUpliftsReadOnly, + upliftRevokeRequiresReason, +} from "@/domain/work-orders/utils/uplift-display-utils"; +import { useAuthContext } from "@/providers/auth-context"; + +type RevokeTarget = { + id: string | number; + status: WorkOrderUpliftStatus; + amount: number; +}; + +type SlideOverUpliftsTabProps = { + workOrderId: string | number; + workOrderType: string; + status: WorkOrderTableRow["status"]; +}; + +function revokeDialogDescription(amount: number | undefined): string { + const money = amount == null ? "" : `${formatUpliftMoney(amount)} `; + return `${money}goes back to your auto-approval allowance. This is recorded in the log.`; +} + +function UpliftNotice({ + background, + icon, + children, +}: { + background: string; + icon: ReactNode; + children: string; +}) { + return ( +
+ {icon} + + {children} + +
+ ); +} + +export function SlideOverUpliftsTab({ + workOrderId, + workOrderType, + status, +}: SlideOverUpliftsTabProps) { + const { user } = useAuthContext(); + const readOnly = isWorkOrderUpliftsReadOnly(status); + const { data: uplifts = [], isLoading, error, refetch } = useWorkOrderUplifts(workOrderId); + const createUplift = useCreateWorkOrderUplift(workOrderId); + const cancelUplift = useCancelWorkOrderUplift(workOrderId); + const revokeUplift = useRevokeWorkOrderUplift(workOrderId); + const [revokeTarget, setRevokeTarget] = useState(null); + const [cancelTargetId, setCancelTargetId] = useState(null); + const hasOpenRequest = hasOpenWorkOrderUplift(uplifts); + const showBody = !isLoading && !error; + const showEmpty = showBody && uplifts.length === 0; + const showForm = showBody && !readOnly && !hasOpenRequest; + const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending; + + return ( +
+ {readOnly && ( + + } + > + This work order is closed. Uplifts can no longer be requested. + + )} + {showBody && !readOnly && hasOpenRequest && ( + + } + > + A request is pending admin approval. Cancel it if you need to submit a different amount. + + )} + {Boolean(error) && ( + void refetch()} + /> + )} + {isLoading && ( +
+ +
+ )} + {showForm && ( + createUplift.mutateAsync(input)} + /> + )} + {showBody && + uplifts.map((uplift) => ( + setCancelTargetId(uplift.id)} + onRevoke={() => + setRevokeTarget({ id: uplift.id, status: uplift.status, amount: uplift.amount }) + } + /> + ))} + {showEmpty && ( + + No uplifts on this work order. + + )} + setCancelTargetId(null)} + onConfirm={() => { + if (cancelTargetId == null) return; + cancelUplift.mutate(cancelTargetId, { onSuccess: () => setCancelTargetId(null) }); + }} + /> + setRevokeTarget(null)} + onConfirm={(reason) => { + if (revokeTarget == null) return; + revokeUplift.mutate( + { upliftId: revokeTarget.id, reason }, + { onSuccess: () => setRevokeTarget(null) }, + ); + }} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts index 7d5deead..a1ddf74d 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -90,13 +90,13 @@ export function useSlideOverEditState({ }: UseSlideOverEditStateArgs) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(null); - /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */ + // why: Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. const [baseline, setBaseline] = useState(null); const [showUnsaved, setShowUnsaved] = useState(false); const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null); - // Reset draft/baseline only when the opened WO identity or editMode intent changes — - // not on every optimistic parent-row field update during a failed/in-flight save. + // why: Reset draft/baseline only when the opened WO identity or editMode intent changes — not on every + // optimistic parent-row field update during a failed/in-flight save. useSlideOverInitialization({ row, editMode, @@ -109,9 +109,9 @@ export function useSlideOverEditState({ setUnsavedIntent, }); - // Re-apply the requested tab on every open, including a second notification for the work order - // already on screen (same row id) where the identity effect above does not re-run. Tab-only so - // an in-flight edit draft is never discarded. + // why: Re-apply the requested tab on every open, including a second notification for the work order + // already on screen (same row id) where the identity effect above does not re-run. Tab-only so an + // in-flight edit draft is never discarded. useEffect(() => { if (!row) return; setTab(editMode ? "info" : initialTab); diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index 78e95ac2..f9a6e607 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -26,15 +26,15 @@ import { useSlideOverMediaState } from "./use-slide-over-media-state"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { SlideOverSaveOptions } from "./work-order-slide-over"; -export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras"; +export type SlideOverTab = "info" | "comments" | "completion" | "uplifts" | "extras" | "audit"; type UseWorkOrderSlideOverArgs = { row: WorkOrderTableRow | null; editMode: boolean; + initialTab?: SlideOverTab; onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onClose: () => void; saving?: boolean; - initialTab?: SlideOverTab; openRequestKey?: number; }; @@ -56,10 +56,10 @@ function uploadSlideOverCompletionPdf({ toast.error("Missing work order version. Refresh and try again."); return; } - // The upload transitions the completion document to Yes and bumps the work-order row - // version in the same server transaction; the mutation's onSuccess refreshes the board. - // A client docStatus patch here would carry the pre-upload version and 409 after a - // successful upload, so it is intentionally omitted. + // why: The upload transitions the completion document to Yes and bumps the work-order row version in + // the same server transaction; the mutation's onSuccess refreshes the board. A client docStatus + // patch here would carry the pre-upload version and 409 after a successful upload, so it is + // intentionally omitted. mutate({ file, workOrderVersion: version }); } @@ -89,10 +89,10 @@ function slideOverErrorMessage(error: unknown) { export function useWorkOrderSlideOver({ row, editMode, + initialTab = "info", onSave, onClose, saving, - initialTab, openRequestKey, }: UseWorkOrderSlideOverArgs) { const { user } = useAuthContext(); @@ -111,7 +111,7 @@ export function useWorkOrderSlideOver({ const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? ""); const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? ""); - const [tab, setTab] = useState("info"); + const [tab, setTab] = useState(initialTab); const { detailForRow, infoSource } = resolveSlideOverSource(detail, row); const readFlags = slideOverReadFlags(infoSource?.status); @@ -146,7 +146,6 @@ export function useWorkOrderSlideOver({ initialTab, openRequestKey, }); - const uploadCompletionPdf = (file: File) => { uploadSlideOverCompletionPdf({ file, diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx index 2efcac54..ce0b6455 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx @@ -3,9 +3,10 @@ import type { SlideOverTab } from "./use-work-order-slide-over"; const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [ { id: "info", label: "Info" }, { id: "comments", label: "Comments" }, - { id: "audit", label: "Audit Log" }, { id: "completion", label: "Completion Doc" }, + { id: "uplifts", label: "Uplifts" }, { id: "extras", label: "Extra Docs" }, + { id: "audit", label: "Audit Log" }, ]; type WorkOrderSlideOverTabNavProps = { diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 12d015fa..9845a85b 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -4,6 +4,7 @@ import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/d import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab"; import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab"; +import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab"; import type { LocationOption, VendorDropdownItem, @@ -17,6 +18,23 @@ import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel"; type SlideOverState = ReturnType; +function SlideOverUpliftsPanel({ + tab, + infoSource, +}: { + tab: SlideOverTab; + infoSource: WorkOrderTableRow | null | undefined; +}) { + if (tab !== "uplifts" || infoSource == null) return null; + return ( + + ); +} + type WorkOrderSlideOverTabsProps = { tab: SlideOverTab; state: SlideOverState; @@ -141,6 +159,8 @@ export function WorkOrderSlideOverTabs({ )} + + {tab === "extras" && ( void; onClose: () => void; saving?: boolean; - initialTab?: SlideOverTab; /** Comments a notification opened the slide-over for; highlighted on the Comments tab. */ commentHighlight?: CommentHighlight | null; /** Increments on every open so re-opening the same work order re-applies tab/highlight. */ @@ -46,6 +46,7 @@ type WorkOrderSlideOverProps = { export function WorkOrderSlideOver({ row, editMode, + initialTab = "info", users, sites, vendors, @@ -58,7 +59,6 @@ export function WorkOrderSlideOver({ onDuplicateFound, onClose, saving, - initialTab, commentHighlight, openRequestKey, }: WorkOrderSlideOverProps) { diff --git a/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx index 6f4c4558..f1066b32 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx @@ -1,48 +1,117 @@ +import type { ReactNode } from "react"; +import { Plus } from "lucide-react"; import type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift"; import { getUpliftCellLabel, - getUpliftStatusPillStyle, + getUpliftCellVisual, + type UpliftCellVisual, } from "@/domain/work-orders/utils/uplift-display-utils"; type UpliftCellProps = { summary: UpliftSummary; + closed?: boolean; onOpen?: () => void; }; -export function UpliftCell({ summary, onOpen }: UpliftCellProps) { - const label = getUpliftCellLabel(summary); +const CELL_DOT: Record<"pending" | "approved" | "rejected", string> = { + pending: "var(--warning)", + approved: "var(--success)", + rejected: "var(--destructive)", +}; - if (!onOpen) { - return label ? ( - {label} - ) : ( - — - ); - } - - const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus; - const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null; +const CELL_STATUS_COLOR: Record<"pending" | "approved" | "rejected", string> = { + pending: "var(--color-warning)", + approved: "var(--color-success)", + rejected: "var(--color-destructive)", +}; +function UpliftCellButton({ + label, + title, + onOpen, + children, +}: { + label: string; + title: string; + onOpen: () => void; + children: ReactNode; +}) { return ( ); } + +function UpliftCellVisualBody({ visual }: { visual: UpliftCellVisual }) { + if (visual.kind === "plain") { + return {visual.label}; + } + const struck = visual.kind === "rejected"; + return ( + <> + + {visual.amount != null && ( + + {visual.amount} + + )} + + {visual.statusLabel} + + + ); +} + +export function UpliftCell({ summary, closed = false, onOpen }: UpliftCellProps) { + const label = getUpliftCellLabel(summary); + const visual = getUpliftCellVisual(summary); + + if (!onOpen) { + return {label ?? "—"}; + } + + if (!label && closed) { + return ( + + — + + ); + } + + if (!label) { + return ( + + + Add + + ); + } + + return ( + + {visual != null && } + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts index 1051ea00..210d9587 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts @@ -11,7 +11,7 @@ export const WO_TABLE_COLS = [ { key: "vendor", label: "VENDOR", w: 184 }, { key: "appt", label: "APPT TIME", w: 128 }, { key: "status", label: "STATUS", w: 148 }, - // Completed Date gates the Completion Document, so it sits between Status and Comp Doc. + // why: Completed Date gates the Completion Document, so it sits between Status and Comp Doc. { key: "completed", label: "COMPLETED DATE", w: 130 }, { key: "doc", label: "COMP DOC", w: 84 }, { key: "uplift", label: "UPLIFT", w: 108 }, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 21731f67..f0a956c3 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -9,7 +9,7 @@ import { StatusCell } from "./cells/status-cell"; import { UpliftCell } from "./cells/uplift-cell"; import { VendorCell } from "./cells/vendor-cell"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; -import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils"; +import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils"; import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts"; import { effectiveCompany } from "@/domain/work-orders/utils/effective-completion-snapshot"; import type { WoTableRowHandlers } from "./wo-table-row"; @@ -132,12 +132,8 @@ export function WoTableRowServiceCells({ e.stopPropagation()}> handlers.onOpenUplifts!(row) - : undefined - } + closed={isWorkOrderUpliftsReadOnly(row.status)} + onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined} /> diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx deleted file mode 100644 index 6bb73f82..00000000 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; - -type WorkOrdersListOverlaysProps = { - activeUpliftsRow: WorkOrderTableRow | null; - onCloseUplifts: () => void; -}; - -export function WorkOrdersListOverlays({ - activeUpliftsRow, - onCloseUplifts, -}: WorkOrdersListOverlaysProps) { - return ( - <> - {activeUpliftsRow != null && ( - - )} - - ); -} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx index 3f230e88..53fd0d51 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -4,7 +4,6 @@ import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet"; import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog"; import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog"; -import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays"; import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard"; import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; @@ -108,11 +107,6 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps }} /> )} - - page.setUpliftsRow(null)} - /> ); } diff --git a/src/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog.tsx new file mode 100644 index 00000000..453dfae0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog.tsx @@ -0,0 +1,61 @@ +import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { guardDialogClose } from "@/lib/dialog-guard"; + +type CancelUpliftDialogProps = { + open: boolean; + pending?: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +export function CancelUpliftDialog({ + open, + pending = false, + onClose, + onConfirm, +}: CancelUpliftDialogProps) { + const handleClose = () => { + if (pending) return; + onClose(); + }; + + return ( + + + Cancel uplift request? + + + + This request will be withdrawn. You can submit a new one afterwards. + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx index 2c7b9584..435bdfae 100644 --- a/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material"; +import { Undo2 } from "lucide-react"; import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; import { Button } from "@/components/ui/button"; import { DialogCancelButton } from "@/components/ui/dialog-cancel-button"; @@ -22,8 +23,8 @@ export function RevokeUpliftDialog({ onConfirm, pending = false, requireReason = false, - title = "Revoke auto-approved uplift?", - description = "This will mark the uplift as revoked. Optional reason is stored with the decision.", + title = "Revoke this uplift?", + description = "goes back to your auto-approval allowance. This is recorded in the log.", }: RevokeUpliftDialogProps) { const [reason, setReason] = useState(""); @@ -47,32 +48,43 @@ export function RevokeUpliftDialog({ return ( - {title} + + {title} + - + {description} - Reason + {requireReason ? "Reason" : "Reason (optional)"} setReason(event.target.value)} /> - + diff --git a/src/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker.tsx b/src/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker.tsx new file mode 100644 index 00000000..48bfe671 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker.tsx @@ -0,0 +1,100 @@ +import { useRef } from "react"; +import { Upload, X } from "lucide-react"; +import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; +import { Text } from "@/components/ui/text"; + +const ACCEPTED_TYPES = new Set(["application/pdf", "image/jpeg", "image/jpg", "image/png"]); + +type UpliftAttachmentPickerProps = { + file: File | null; + onChange: (file: File | null) => void; +}; + +function isAllowedUpliftEvidenceFile(file: File): boolean { + if (ACCEPTED_TYPES.has(file.type)) return true; + return /\.(pdf|jpe?g|png)$/i.test(file.name); +} + +function firstAllowedFile(incoming: FileList | null): File | null { + if (!incoming || incoming.length === 0) return null; + return Array.from(incoming).find(isAllowedUpliftEvidenceFile) ?? null; +} + +function UpliftStagedFile({ file, onRemove }: { file: File; onRemove: () => void }) { + return ( +
+ + {file.name} + + +
+ ); +} + +export function UpliftAttachmentPicker({ file, onChange }: UpliftAttachmentPickerProps) { + const inputRef = useRef(null); + + const stageFile = (incoming: FileList | null) => { + const next = firstAllowedFile(incoming); + if (next) onChange(next); + }; + + return ( +
+ Attachment + + Optional — PDF, JPG, or PNG. + + + { + stageFile(event.target.files); + event.target.value = ""; + }} + /> + {file != null && ( +
+ onChange(null)} /> +
+ )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx index 8e0902e6..39995a86 100644 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx @@ -1,23 +1,60 @@ import { useState } from "react"; import { Button, TextField } from "@mui/material"; +import { AlertTriangle, Check, Plus } from "lucide-react"; +import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; +import { UpliftAttachmentPicker } from "@/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker"; import { Text } from "@/components/ui/text"; -import { formatUpliftMoney } from "@/domain/work-orders/utils/uplift-display-utils"; +import { + upliftAllowanceHelper, + type UpliftAllowanceHelper, + type UpliftAllowanceHelperTone, +} from "@/domain/work-orders/utils/uplift-display-utils"; type WorkOrderUpliftCreateFormProps = { pending?: boolean; remainingAllowance: number; - onSubmit: (input: { amount: number; notes: string }) => void | Promise; + onSubmit: (input: { + amount: number; + notes: string; + file: File | null; + }) => void | Promise; }; -function amountHelperText(parsedAmount: number, remainingAllowance: number): string { - const remaining = `Remaining auto-approval: ${formatUpliftMoney(remainingAllowance)}.`; - if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { - return remaining; - } - if (parsedAmount <= remainingAllowance) { - return `${remaining} This request will auto-approve.`; - } - return `${remaining} This request will go to the admin approval queue.`; +function canSubmitUplift(amount: number, notes: string): boolean { + return Number.isFinite(amount) && amount > 0 && notes.trim().length > 0; +} + +const ALLOWANCE_HINT_COLOR: Record = { + muted: "var(--muted-foreground)", + success: "var(--status-completed-text)", + warning: "var(--warning)", +}; + +function UpliftAllowanceHint({ helper }: { helper: UpliftAllowanceHelper }) { + return ( + + {helper.icon === "check" && } + {helper.icon === "alert" && } + {helper.text} + + ); } export function WorkOrderUpliftCreateForm({ @@ -27,57 +64,69 @@ export function WorkOrderUpliftCreateForm({ }: WorkOrderUpliftCreateFormProps) { const [amount, setAmount] = useState(""); const [notes, setNotes] = useState(""); - + const [file, setFile] = useState(null); const parsedAmount = Number(amount); - const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0; + const helper = upliftAllowanceHelper(parsedAmount, remainingAllowance); + const canSubmit = canSubmitUplift(parsedAmount, notes); const handleSubmit = async () => { try { - await onSubmit({ amount: parsedAmount, notes: notes.trim() }); + await onSubmit({ amount: parsedAmount, notes: notes.trim(), file }); setAmount(""); setNotes(""); + setFile(null); } catch { - // Mutation onError already toasts; keep form values after failure. + // why: the mutation toasts onError, so a failed submit keeps the typed values. } }; return ( -
- - Request uplift - -
+
+ + Amount ($) + + setAmount(event.target.value)} + slotProps={{ htmlInput: { min: 0, step: "0.01" } }} + /> + + +
+ + Notes + setAmount(event.target.value)} - helperText={amountHelperText(parsedAmount, remainingAllowance)} - slotProps={{ htmlInput: { min: 0, step: "0.01" } }} - /> - setNotes(event.target.value)} /> - +
+ + + +
diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx index 0578302c..b1a7b5e3 100644 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx @@ -1,13 +1,42 @@ -import { Button } from "@mui/material"; +import { useSyncExternalStore } from "react"; +import { Button, Tooltip } from "@mui/material"; +import { FileText, Undo2 } from "lucide-react"; +import { toast } from "react-toastify"; import { Text } from "@/components/ui/text"; -import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import type { + WorkOrderUplift, + WorkOrderUpliftAttachment, +} from "@/domain/work-orders/types/work-order-uplift"; import { canRevokeWorkOrderUplift, + formatUpliftCardMeta, formatUpliftMoney, formatUpliftStatusLabel, getUpliftStatusPillStyle, + isVoidUpliftStatus, } from "@/domain/work-orders/utils/uplift-display-utils"; +const CLOSED_REVOKE_HINT = "This work order is closed. Uplifts can no longer be revoked."; + +let upliftClock = Date.now(); + +function subscribeUpliftClock(onChange: () => void) { + const id = window.setInterval(() => { + upliftClock = Date.now(); + onChange(); + }, 60_000); + return () => window.clearInterval(id); +} + +function readUpliftClock(): number { + return upliftClock; +} + +function useUpliftClock(): number { + return useSyncExternalStore(subscribeUpliftClock, readUpliftClock, readUpliftClock); +} + type WorkOrderUpliftListItemProps = { uplift: WorkOrderUplift; readOnly: boolean; @@ -17,64 +46,200 @@ type WorkOrderUpliftListItemProps = { onRevoke?: () => void; }; -export function WorkOrderUpliftListItem({ - uplift, - readOnly, - currentUserId, - pendingAction = false, - onCancel, - onRevoke, -}: WorkOrderUpliftListItemProps) { +function UpliftStatusPill({ uplift }: { uplift: WorkOrderUplift }) { const pillStyle = getUpliftStatusPillStyle(uplift.status); - const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel); - const showRevoke = - !readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke); - return ( -
-
- + {formatUpliftStatusLabel(uplift.status)} + + ); +} + +function UpliftFiledAttachment({ + upliftId, + file, +}: { + upliftId: string | number; + file: WorkOrderUpliftAttachment; +}) { + const hasUrl = file.url !== ""; + const downloadEvidence = () => { + void upliftsApi.downloadEvidence(upliftId, file.name).catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : "Unable to download evidence."); + }); + }; + return ( + - {Boolean(uplift.notes) && ( - - {uplift.notes} - + {file.name} + )} - {Boolean(uplift.requestedByName) && ( - - Requested by {uplift.requestedByName} - - )} - {showCancel && ( - - )} - {showRevoke && ( - + {file.name} + )}
); } + +function UpliftDecisionCallout({ uplift }: { uplift: WorkOrderUplift }) { + const rejected = uplift.status === "rejected" && uplift.decisionNote !== ""; + const revoked = uplift.status === "revoked" && uplift.decisionNote !== ""; + if (!rejected && !revoked) return null; + return ( + + {uplift.decisionNote} + + ); +} + +function UpliftCardActions({ + uplift, + readOnly, + currentUserId, + pendingAction, + onCancel, + onRevoke, +}: WorkOrderUpliftListItemProps) { + const nowMs = useUpliftClock(); + const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel); + const showRevoke = canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke); + const revokeBlocked = readOnly || pendingAction; + + return ( +
+ + {formatUpliftCardMeta(uplift, nowMs)} + + {showCancel && ( + + )} + {showRevoke && ( + + + + + + )} +
+ ); +} + +export function WorkOrderUpliftListItem(props: WorkOrderUpliftListItemProps) { + const { uplift } = props; + const isVoid = isVoidUpliftStatus(uplift.status); + const attachments = uplift.attachments ?? []; + const pendingBorder = uplift.status === "pending"; + + return ( +
+
+ + {formatUpliftMoney(uplift.amount)} + + +
+ {uplift.notes !== "" && ( + + {uplift.notes} + + )} + {attachments.length > 0 && ( +
+ {attachments.map((file) => ( + + ))} +
+ )} + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx deleted file mode 100644 index b0c0c6a0..00000000 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { CircularProgress } from "@mui/material"; -import { QueryErrorPanel } from "@/components/ui/query-error-panel"; -import { Text } from "@/components/ui/text"; -import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form"; -import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item"; -import type { - CreateWorkOrderUpliftInput, - WorkOrderUplift, -} from "@/domain/work-orders/types/work-order-uplift"; -import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-display-utils"; - -type WorkOrderUpliftsDialogContentProps = { - readOnly: boolean; - currentUserId: string | number | null | undefined; - readOnlyStatusLabel?: string; - isLoading: boolean; - error: Error | null; - uplifts: WorkOrderUplift[]; - remainingAllowance: number; - actionPending: boolean; - createPending: boolean; - onRetry: () => void; - onCreate: (input: CreateWorkOrderUpliftInput) => void | Promise; - onCancelUplift: (upliftId: string | number) => void; - onRevokeUplift: (upliftId: string | number) => void; -}; - -export function WorkOrderUpliftsDialogContent({ - readOnly, - currentUserId, - readOnlyStatusLabel, - isLoading, - error, - uplifts, - remainingAllowance, - actionPending, - createPending, - onRetry, - onCreate, - onCancelUplift, - onRevokeUplift, -}: WorkOrderUpliftsDialogContentProps) { - const hasOpenRequest = hasOpenWorkOrderUplift(uplifts); - - return ( - <> - {readOnly && ( - - This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only. - - )} - - {Boolean(error) && ( - - )} - - {isLoading && ( -
- -
- )} - - {!isLoading && !error && uplifts.length === 0 && ( - - No uplift requests yet. - - )} - - {!isLoading && - uplifts.map((uplift) => ( - onCancelUplift(uplift.id)} - onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)} - /> - ))} - - {!readOnly && !isLoading && hasOpenRequest && ( - - Cancel the pending request to file a new one. - - )} - - {!readOnly && !isLoading && !hasOpenRequest && ( - - )} - - ); -} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx deleted file mode 100644 index 7e6cbc27..00000000 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useMemo, useState } from "react"; -import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; -import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog"; -import { WorkOrderUpliftsDialogContent } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content"; -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift"; -import { - useCancelWorkOrderUplift, - useCreateWorkOrderUplift, - useRevokeWorkOrderUplift, - useWorkOrderUplifts, -} from "@/domain/work-orders/use-cases/use-work-order-uplifts"; -import { - getRemainingUpliftAllowance, - isWorkOrderUpliftsReadOnly, - upliftRevokeRequiresReason, -} from "@/domain/work-orders/utils/uplift-display-utils"; -import { useAuthContext } from "@/providers/auth-context"; - -type RevokeTarget = { - id: string | number; - status: WorkOrderUpliftStatus; -}; - -type WorkOrderUpliftsDialogProps = { - row: WorkOrderTableRow | null; - open: boolean; - onClose: () => void; -}; - -export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) { - const { user } = useAuthContext(); - const workOrderId = row?.id ?? null; - const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true; - const { - data: uplifts = [], - isLoading, - error, - refetch, - } = useWorkOrderUplifts(open ? workOrderId : null); - const createUplift = useCreateWorkOrderUplift(workOrderId ?? ""); - const cancelUplift = useCancelWorkOrderUplift(workOrderId ?? ""); - const revokeUplift = useRevokeWorkOrderUplift(workOrderId ?? ""); - const [revokeTarget, setRevokeTarget] = useState(null); - - const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending; - - const revokeDialogCopy = useMemo(() => { - if (revokeTarget?.status === "approved") { - return { - title: "Revoke approved uplift?", - description: - "Admin revoke requires a reason. This will mark the uplift as revoked and store your note with the decision.", - }; - } - return { - title: "Revoke auto-approved uplift?", - description: - "This will mark the uplift as revoked. Optional reason is stored with the decision.", - }; - }, [revokeTarget?.status]); - - const handleRevokeUplift = (upliftId: string | number) => { - const uplift = uplifts.find((item) => String(item.id) === String(upliftId)); - if (!uplift) { - return; - } - setRevokeTarget({ id: upliftId, status: uplift.status }); - }; - - const remainingAllowance = getRemainingUpliftAllowance(row?.type ?? "", uplifts); - - return ( - <> - - Uplifts — WO {row?.woNumber ?? ""} - - void refetch()} - onCreate={async (input) => { - await createUplift.mutateAsync(input); - }} - onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)} - onRevokeUplift={handleRevokeUplift} - /> - - - - - - - setRevokeTarget(null)} - onConfirm={(reason) => { - if (revokeTarget == null) { - return; - } - revokeUplift.mutate( - { upliftId: revokeTarget.id, reason }, - { onSuccess: () => setRevokeTarget(null) }, - ); - }} - /> - - ); -} diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts index 326b6ba7..3230fe29 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts @@ -65,11 +65,8 @@ export function useNewWoWizardController({ }; const createWorkOrder = useCreateWorkOrderFromWizard((createdDraft) => { - // Close the dialog first: the work-order create's isPending has already dropped, so - // holding the dialog open through the notes save would re-enable Create and unlock - // pointer events, and a second click would create a second work order. The save runs - // independently of the dialog — fetchCurrentBaseline covers a missing baseline and the - // note-save failure toast is surfaced from the mutation itself, not the dialog. + // hazard: Create's isPending has already dropped, so an open dialog re-enables Create and a + // second click creates another work order. Close first; the notes save continues on its own. onOpenChange(false); void saveCompanyNotes(createdDraft); }); diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts index a18f1550..afd919d4 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts @@ -29,7 +29,7 @@ export function buildCreatedTechniciansPatch( ): Partial { const patch: Partial = { newTechnicians: [] }; const [only] = created; - // Prototype parity: a single new technician is assigned when none was picked. + // why: Prototype parity: a single new technician is assigned when none was picked. if (created.length === 1 && only != null && draft.vendorId === "") { patch.vendorId = String(only.id); patch.vendorName = company; @@ -57,9 +57,9 @@ export function useWizardCreateWithTechnicians({ createMutation.mutate(draft); return; } - // Mirror the vendor roster schema so an unparseable phone is caught here with a - // clear message instead of failing the roster PATCH and blocking Create with the - // raw backend sentence. A blank phone stays valid, matching the modal path. + // why: Mirror the vendor roster schema so an unparseable phone is caught here with a clear message + // instead of failing the roster PATCH and blocking Create with the raw backend sentence. A blank + // phone stays valid, matching the modal path. if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) { toast.error("Enter a 10-digit phone number for each new technician."); return; @@ -80,7 +80,7 @@ export function useWizardCreateWithTechnicians({ })), }); const patch = buildCreatedTechniciansPatch(draft, company, pending, created); - // Clear the staged rows first so a failed WO create cannot re-create them on retry. + // why: Clear the staged rows first so a failed WO create cannot re-create them on retry. patchDraft(patch); createMutation.mutate({ ...draft, ...patch }); } catch (error) { diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts index 73625b6a..5f7048b4 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts @@ -40,7 +40,7 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set useEffect(() => { if (!vendorId) { - // Cleared technician: the next pick re-seeds even for the same company. + // why: Cleared technician: the next pick re-seeds even for the same company. if (baseline) setBaseline(null); return; } @@ -64,8 +64,8 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set if (current.vendorId !== vendorId) { return current; } - // Seed only empty content or content still holding the previous company's unedited - // notes; anything else is the dispatcher's typing and must never be overwritten. + // why: Seed only empty content or content still holding the previous company's unedited notes; anything + // else is the dispatcher's typing and must never be overwritten. if (current.vendorNotes !== "" && current.vendorNotes !== baseline?.notes) { return current; } @@ -112,7 +112,7 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set try { await saveNotes.mutateAsync(update); } catch { - // The mutation already surfaces the note-save failure toast. + // why: The mutation already surfaces the note-save failure toast. } } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index 7a655742..206ae9c0 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -274,9 +274,9 @@ export function WizardStepTypeSchedule({ onPatch({ type: t, severity: nextSeverityForType(draft.type, t, draft.severity), - // A registry service is scoped to its work-order type, so a service picked - // under the previous type fails backend ResolveServiceAsync (SH-187/#131). - // Clear the service (and its vendor, filtered by the service) on a type change. + // why: A registry service is scoped to its work-order type, so a service picked under the previous type + // fails backend ResolveServiceAsync (SH-187/#131). Clear the service (and its vendor, filtered by + // the service) on a type change. ...(typeChanged ? { pm: "", diff --git a/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts index 98c9edab..c55b29b9 100644 --- a/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts +++ b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts @@ -162,8 +162,8 @@ export function createCompDocDialogActions({ printDoc(buildDocHtml(row, { notes, generatedAt }, docFields)); } - // Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes - // and bumps the work-order row version in one transaction. Reflect that in the UI and refresh + // why: Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes and + // bumps the work-order row version in one transaction. Reflect that in the UI and refresh // authoritative state, but do not re-run the Generate persist — that patch would carry the // pre-upload row version and 409, surfacing an error right after a successful upload. function markSignedPdfUploaded() { diff --git a/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts b/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts index 1306fc20..6cd58e80 100644 --- a/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts +++ b/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts @@ -8,8 +8,8 @@ export function useDashboardDrilldownFilters( ): boolean { const [searchParams, setSearchParams] = useSearchParams(); const appliedRef = useRef(false); - // Hold onApply in a ref so a caller passing a fresh closure each render does not - // re-run the apply effect; it should fire only when the URL search params change. + // why: Hold onApply in a ref so a caller passing a fresh closure each render does not re-run the apply + // effect; it should fire only when the URL search params change. const onApplyRef = useRef(onApply); const [fromDashboard, setFromDashboard] = useState(false); @@ -20,7 +20,7 @@ export function useDashboardDrilldownFilters( useEffect(() => { const drilldown = parseDashboardDrilldownParams(searchParams); if (!drilldown) { - // The applied params were cleared; a later drilldown link on this mounted page applies too. + // why: The applied params were cleared; a later drilldown link on this mounted page applies too. appliedRef.current = false; return; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts index cbc1da83..3811a368 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts @@ -33,7 +33,7 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar () => buildResolvedBoardSearchParams({ advApplied: filters.advApplied, - // Advanced Filters are global by design; the week-scoped keyword box has no effect. + // why: Advanced Filters are global by design; the week-scoped keyword box has no effect. search: "", page: filters.page, pageSize: filters.pageSize, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 113dec56..2d8eee1c 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -24,8 +24,8 @@ function useSlideOverOpenState() { const [slideOverEdit, setSlideOverEdit] = useState(false); const [slideOverTab, setSlideOverTab] = useState("info"); const [slideOverHighlight, setSlideOverHighlight] = useState(null); - // Bumps on every open() so the slide-over re-applies tab/highlight even when a second - // notification targets the work order already on screen (same row id). + // why: Bumps on every open() so the slide-over re-applies tab/highlight even when a second notification + // targets the work order already on screen (same row id). const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0); const handleOpenSlideOver = ( @@ -73,7 +73,6 @@ export function useWorkOrdersListPage() { const [confirmComplete, setConfirmComplete] = useState(null); const [docRow, setDocRow] = useState(null); const [mediaRow, setMediaRow] = useState(null); - const [upliftsRow, setUpliftsRow] = useState(null); const [completionDocs, setCompletionDocs] = useState>({}); const filters = useWorkOrdersListFilters(); const tableData = useWorkOrdersTableData(filters); @@ -117,10 +116,9 @@ export function useWorkOrdersListPage() { return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow; }, [mediaRow, tableData.rows]); - const activeUpliftsRow = useMemo(() => { - if (!upliftsRow) return null; - return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow; - }, [upliftsRow, tableData.rows]); + const handleOpenUplifts = (row: WorkOrderTableRow) => { + handleOpenSlideOver(row, false, "uplifts"); + }; const handleCloseSlideOver = () => { closeSlideOverState(); @@ -160,11 +158,11 @@ export function useWorkOrdersListPage() { return; } try { - // Generation is the one automatic completion — no confirmation dialog. + // why: Generation is the one automatic completion — no confirmation dialog. await patchFieldAsync(row.id, { status: "Completed" }, saved.row); } catch { - // The document is saved; the table mutation already surfaced the status error and - // refetched, so the WO stays in its prior status for a manual retry. + // why: The document is saved; the table mutation already surfaced the status error and refetched, so + // the WO stays in its prior status for a manual retry. } }; @@ -183,7 +181,6 @@ export function useWorkOrdersListPage() { setDocRow, mediaRow, setMediaRow, - setUpliftsRow, completionDocs, filters, tableData, @@ -199,8 +196,8 @@ export function useWorkOrdersListPage() { activeSlideOverRow, activeDocRow, activeMediaRow, - activeUpliftsRow, handleOpenSlideOver, + handleOpenUplifts, handleCloseSlideOver, handleConfirmCancel, handleConfirmComplete, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 0e6c0b98..307ee5ed 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -32,7 +32,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { }); const { displayRows, ...localUi } = local; - // SH-338: the server owns rows/totals for advanced search (incl. Unscheduled). + // why: SH-338: the server owns rows/totals for advanced search (incl. Unscheduled). const advResults = useMemo( () => (advActive ? displayRows : EMPTY_WEEKLY_ITEMS), [advActive, displayRows], diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 95c54dfe..bde2497e 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -116,7 +116,7 @@ export default function WorkOrdersListPage() { onRequestComplete={page.setConfirmComplete} onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)} onOpenMedia={page.setMediaRow} - onOpenUplifts={page.setUpliftsRow} + onOpenUplifts={page.handleOpenUplifts} onClearSearch={() => filters.handleSearchChange("")} onOpenAdvancedFilters={() => filters.setAdvOpen(true)} onPatch={page.patchField} diff --git a/src/components/common/address-autocomplete-field.tsx b/src/components/common/address-autocomplete-field.tsx index 30e344c6..f8551f09 100644 --- a/src/components/common/address-autocomplete-field.tsx +++ b/src/components/common/address-autocomplete-field.tsx @@ -44,16 +44,14 @@ export function AddressAutocompleteField({ freeSolo disabled={disabled} options={suggestAddresses(value)} - // The options are already derived from the input; letting MUI filter them - // again would drop every row whose label does not literally contain the - // typed text. + // why: The options are already derived from the input; letting MUI filter them again would drop every + // row whose label does not literally contain the typed text. filterOptions={(options) => options} inputValue={value} onInputChange={(_event, next, reason) => { - // Genuine typing and the clear control update the street. MUI also - // fires this callback with reason "reset" after a selection, carrying - // the option's full ", , " label — forwarding - // that would overwrite the just-picked street with the label. + // why: Genuine typing and the clear control update the street. MUI also fires this callback with reason + // "reset" after a selection, carrying the option's full ", , " label — + // forwarding that would overwrite the just-picked street with the label. if (reason !== "input" && reason !== "clear") return; onInputChange(next); }} @@ -74,9 +72,8 @@ export function AddressAutocompleteField({ error={error} helperText={helperText} fullWidth - // Required is set on the input itself, never via MUI's `required` - // prop: this codebase spells the requirement in the label text and - // asserts that no generated asterisk renders anywhere. + // why: Required is set on the input itself, never via MUI's `required` prop: this codebase spells the + // requirement in the label text and asserts that no generated asterisk renders anywhere. slotProps={{ ...params.slotProps, htmlInput: { ...params.slotProps?.htmlInput, required }, diff --git a/src/components/common/address-map-preview.tsx b/src/components/common/address-map-preview.tsx index 5d1892ce..06db39ca 100644 --- a/src/components/common/address-map-preview.tsx +++ b/src/components/common/address-map-preview.tsx @@ -47,8 +47,8 @@ export function AddressMapPreview({ height = 160, placeholderWhenIncomplete = false, }: AddressMapPreviewProps) { - // Structured input is held to the completeness rule; a pre-composed line is - // saved data and only has to be non-empty. + // why: Structured input is held to the completeness rule; a pre-composed line is saved data and only + // has to be non-empty. const query = address ? buildAddressQuery(address) : (rawQuery ?? "").trim(); const complete = address ? hasCompleteAddress(address) : Boolean(query); diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx index ed254ab7..096fd104 100644 --- a/src/components/notifications/notification-row.tsx +++ b/src/components/notifications/notification-row.tsx @@ -37,7 +37,8 @@ const ICON_PROPS = { style: { color: "var(--color-text-muted)", marginTop: 2 }, } as const; -// Recent activity mixes comments, assignments and uplift decisions; where the row leads tells them apart. +// why: Recent activity mixes comments, assignments and uplift decisions; where the row leads tells them +// apart. function ItemIcon({ item }: { item: NotificationItem }) { if (item.reason === "activity" && item.target.tab === "uplifts") { return ; diff --git a/src/domain/dashboard/utils/work-order-drilldown-links.ts b/src/domain/dashboard/utils/work-order-drilldown-links.ts index a89fc69c..d64eed9b 100644 --- a/src/domain/dashboard/utils/work-order-drilldown-links.ts +++ b/src/domain/dashboard/utils/work-order-drilldown-links.ts @@ -88,12 +88,9 @@ export function scheduledTomorrowDrilldownSearch( } export function avetaPendingDrilldownSearch(today: string = businessTodayIso()): URLSearchParams { - // The Aveta Pending count (backend#138 / SH-294) is: AvetaRequired, scheduled - // today or tomorrow, open, and missing its Aveta-category document. The board - // has no missing-Aveta-document filter, so the closest reachable set is the - // Aveta-required work in the two-day open window. `docs=No` is deliberately - // NOT sent: it filters on completion-document status, a different document - // from the Aveta clearance one, so it would narrow to the wrong rows. + // why: Aveta Pending (SH-294) is Aveta-required, open, today or tomorrow, missing that document. + // The board cannot filter a missing Aveta document, so the link is that two-day open set. + // hazard: do not send docs=No — that is completion-doc status, a different document. const search = new URLSearchParams(); search.set("dateFrom", today); search.set("dateTo", addDaysIso(today, 1)); diff --git a/src/domain/notifications/types/notification-feed.ts b/src/domain/notifications/types/notification-feed.ts index fc5e7bdf..0142e6d0 100644 --- a/src/domain/notifications/types/notification-feed.ts +++ b/src/domain/notifications/types/notification-feed.ts @@ -33,7 +33,7 @@ const itemSchema = z.object({ id: z.string(), reason: z.string(), severity: severitySchema, - // "acknowledge" rows (a missed response deadline) never take the normal dismiss. + // why: "acknowledge" rows (a missed response deadline) never take the normal dismiss. rowType: z.enum(["dismissable", "acknowledge"]), title: z.string(), count: z.number(), diff --git a/src/domain/notifications/use-cases/use-notification-feed.ts b/src/domain/notifications/use-cases/use-notification-feed.ts index 98e9786b..64be38f4 100644 --- a/src/domain/notifications/use-cases/use-notification-feed.ts +++ b/src/domain/notifications/use-cases/use-notification-feed.ts @@ -10,7 +10,7 @@ export function useNotificationFeed() { queryKey: queryKeys.notifications.feed(), queryFn: notificationsApi.feed, refetchInterval: NOTIFICATION_FEED_REFRESH_MS, - // The feed shows its own error state; a toast on every 30s poll would repeat the same failure. + // why: The feed shows its own error state; a toast on every 30s poll would repeat the same failure. meta: { suppressErrorToast: true }, }); } diff --git a/src/domain/services/use-cases/use-services-registry-controller.ts b/src/domain/services/use-cases/use-services-registry-controller.ts index 14e2a6ba..e0ca792b 100644 --- a/src/domain/services/use-cases/use-services-registry-controller.ts +++ b/src/domain/services/use-cases/use-services-registry-controller.ts @@ -25,9 +25,9 @@ export const EMPTY_SERVICE_FORM: ServiceForm = { supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"], }; -// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not -// the legacy DropdownOptions seed. A trade with no explicit default falls back -// to "wrench" where this map is read. +// why: Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not the legacy +// DropdownOptions seed. A trade with no explicit default falls back to "wrench" where this map is +// read. export const DEFAULT_ICON_BY_TRADE: Record = { HVAC: "droplets", "Plumbing & Water Systems": "droplets", @@ -87,9 +87,9 @@ function formFromService(service: Service): ServiceForm { }; } -// Owns the add/edit form lifecycle so the top-level controller stays within the -// changed-file maintainability budget. Mutations are passed in so `isSaving` and -// `closeEditor` share the exact instances the controller reports on. +// why: Owns the add/edit form lifecycle so the top-level controller stays within the changed-file +// maintainability budget. Mutations are passed in so `isSaving` and `closeEditor` share the exact +// instances the controller reports on. function useServiceEditor( canonicalTrades: string[], canDeactivate: boolean, @@ -102,9 +102,9 @@ function useServiceEditor( const [iconTouched, setIconTouched] = useState(false); const [formError, setFormError] = useState(""); - // Keep the trade stored on the service being edited selectable even if it was - // later deactivated in the canonical Trade catalog, so editing never silently - // blanks the field and forces an unrelated trade change. + // why: Keep the trade stored on the service being edited selectable even if it was later deactivated in + // the canonical Trade catalog, so editing never silently blanks the field and forces an unrelated + // trade change. const trades = useMemo(() => { if (form.trade && !canonicalTrades.includes(form.trade)) { return [form.trade, ...canonicalTrades]; @@ -208,11 +208,10 @@ export function useServicesRegistryController() { const [deactivateTarget, setDeactivateTarget] = useState(null); const servicesQuery = useServices(); - // The backend rejects any Trade outside the canonical TradeCatalog (400 - // TradeInvalid), so the picker is sourced from the server-owned canonical - // Trade reference exposed on Vendor/facets — the same list the vendor roster - // form uses — never the editable DropdownOptions "Trade" category, whose - // legacy seed values ("Plumbing", "General", ...) would fail on save. + // why: The backend rejects any Trade outside the canonical TradeCatalog (400 TradeInvalid), so the + // picker is sourced from the server-owned canonical Trade reference exposed on Vendor/facets — the + // same list the vendor roster form uses — never the editable DropdownOptions "Trade" category, + // whose legacy seed values ("Plumbing", "General", ...) would fail on save. const facetsQuery = useVendorFacets(); const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]); const { data: templates = [] } = useCompletionDocTemplates(); diff --git a/src/domain/uplifts/use-cases/use-uplift-actions.ts b/src/domain/uplifts/use-cases/use-uplift-actions.ts index 1de4d83b..8dd479b8 100644 --- a/src/domain/uplifts/use-cases/use-uplift-actions.ts +++ b/src/domain/uplifts/use-cases/use-uplift-actions.ts @@ -26,8 +26,8 @@ function invalidateUpliftQueries( dispatchId?: string | number, ) { void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all }); - // Queue decisions update the same uplift records rendered by Work Order views. - // Invalidate that shared cache even when the queue has no dispatch context. + // why: Queue decisions update the same uplift records rendered by Work Order views. Invalidate that + // shared cache even when the queue has no dispatch context. void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); if (dispatchId !== undefined && dispatchId !== "") { void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.forDispatch(dispatchId) }); diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index 3d925b76..0f298c14 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -77,8 +77,8 @@ export const vendorsApi = { }; }, - // SH-281: grouped company rows come from the dedicated directory endpoint; - // Vendor/GetVendorList stays on the legacy technician-level contract. + // why: SH-281: grouped company rows come from the dedicated directory endpoint; Vendor/GetVendorList + // stays on the legacy technician-level contract. getDirectoryList: async (params: VendorsListParams = {}): Promise => { const data = await apiGet(API_PATHS.vendor.getDirectoryList, { searchParams: buildListParams(params), diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts index 9fd986d0..ece4c174 100644 --- a/src/domain/vendors/mappers/vendor-roster-mapper.ts +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -131,15 +131,15 @@ export function mapVendorRosterToBackend(values: unknown): Record = { ...patch.companyFields }; if (companyFields.companyPhone !== undefined) companyFields.companyPhone = toCanonicalPhone(companyFields.companyPhone); - // An edited Area is sent as an id or an explicit null (Unassigned). + // why: An edited Area is sent as an id or an explicit null (Unassigned). if (companyFields.areaId !== undefined) companyFields.areaId = toBackendAreaId(companyFields.areaId); payload.companyFields = companyFields; diff --git a/src/domain/vendors/schemas/vendor-roster-schema.ts b/src/domain/vendors/schemas/vendor-roster-schema.ts index 1f590cbe..e538b1ae 100644 --- a/src/domain/vendors/schemas/vendor-roster-schema.ts +++ b/src/domain/vendors/schemas/vendor-roster-schema.ts @@ -42,11 +42,10 @@ const baseCompanyFields = { companyContact: z.never().optional(), companyPhone: northAmericanPhone, email: optionalEmail, - // SH-271: the single combined Address line became three structured parts, - // all required, and the stored Google Maps URL was retired from the form. - // `googleMapsUrl` stays on the read models for legacy records but is not a - // form value: loading it would validate and re-emit a deprecated field the - // user can no longer see or edit. + // why: SH-271: the single combined Address line became three structured parts, all required, and the + // stored Google Maps URL was retired from the form. `googleMapsUrl` stays on the read models for + // legacy records but is not a form value: loading it would validate and re-emit a deprecated field + // the user can no longer see or edit. address: z.string().trim().min(1, "Street Address is required"), city: z.string().trim().min(1, "City is required"), state: z.string().trim().min(1, "State is required"), @@ -54,7 +53,7 @@ const baseCompanyFields = { notes: z .string() .max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`), - // Optional stable Area id; "" means Unassigned. Admin-only to change. + // why: Optional stable Area id; "" means Unassigned. Admin-only to change. areaId: z.string(), technicians: z.array(rosterTechnicianSchema), }; diff --git a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts index 89e35054..ad469fd5 100644 --- a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts +++ b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts @@ -26,9 +26,8 @@ export interface SaveVendorCompanyRosterContext { conflict?: unknown; } -// `googleMapsUrl` is deliberately absent: it is a deprecated read-only field, -// so neither the additive companyFields patch nor the status-only diff may -// ever transmit it. +// why: `googleMapsUrl` is deliberately absent: it is a deprecated read-only field, so neither the +// additive companyFields patch nor the status-only diff may ever transmit it. const COMPANY_FIELDS = [ "name", "companyPhone", diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts index fbd7bf77..b8fcf838 100644 --- a/src/domain/work-orders/api/work-order-board-documents-api.ts +++ b/src/domain/work-orders/api/work-order-board-documents-api.ts @@ -216,10 +216,10 @@ export const workOrderBoardDocumentsApi = { workOrderId: string | number, mediaId: string | number, ): Promise => { - // No `credentials: "include"`: this endpoint authenticates with the bearer token the ky - // beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the - // API's `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document - // would never open. + // why: No `credentials: "include"`: this endpoint authenticates with the bearer token the ky + // beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the API's + // `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document would never + // open. const response = await apiRequestRaw( "get", API_PATHS.workOrder.mediaContent(workOrderId, mediaId), diff --git a/src/domain/work-orders/api/work-order-uplifts-api.ts b/src/domain/work-orders/api/work-order-uplifts-api.ts index 866ff164..c37c2e36 100644 --- a/src/domain/work-orders/api/work-order-uplifts-api.ts +++ b/src/domain/work-orders/api/work-order-uplifts-api.ts @@ -1,5 +1,5 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiGet, apiPost } from "@/api/api"; +import { apiGet, apiPost, apiPostForm } from "@/api/api"; import { handleApiResponse } from "@/api/handle-api-response"; import { mapWorkOrderUplift, @@ -19,6 +19,22 @@ function extractUpliftItems(data: unknown): unknown[] { return Array.isArray(items) ? items : []; } +export type UpliftEvidenceSnapshot = { + id: number; + scanStatus: string; + originalFileName: string; +}; + +function readEvidenceSnapshot(data: unknown): UpliftEvidenceSnapshot { + const record = handleApiResponse>(data); + const id = Number(record.id ?? record.Id); + return { + id, + scanStatus: String(record.scanStatus ?? record.ScanStatus ?? ""), + originalFileName: String(record.originalFileName ?? record.OriginalFileName ?? ""), + }; +} + export const workOrderUpliftsApi = { list: async (workOrderId: string | number): Promise => { const data = await apiGet(API_PATHS.workOrder.uplifts(workOrderId)); @@ -30,13 +46,37 @@ export const workOrderUpliftsApi = { workOrderId: string | number, input: CreateWorkOrderUpliftInput, ): Promise => { - const data = await apiPost(API_PATHS.workOrder.uplifts(workOrderId), { + const body: { amount: number; notes: string; evidenceDocumentId?: number } = { amount: input.amount, notes: input.notes, - }); + }; + if (input.evidenceDocumentId != null) { + body.evidenceDocumentId = input.evidenceDocumentId; + } + const data = await apiPost(API_PATHS.workOrder.uplifts(workOrderId), body); return mapWorkOrderUplift(handleApiResponse(data)); }, + uploadEvidence: async ( + workOrderId: string | number, + file: File, + ): Promise => { + const body = new FormData(); + body.append("file", file); + const data = await apiPostForm(API_PATHS.workOrder.upliftEvidence(workOrderId), body); + return readEvidenceSnapshot(data); + }, + + getEvidenceStatus: async ( + workOrderId: string | number, + documentId: number, + ): Promise => { + const data = await apiGet( + API_PATHS.workOrder.upliftEvidenceStatus(workOrderId, documentId), + ); + return readEvidenceSnapshot(data); + }, + cancel: async ( workOrderId: string | number, upliftId: string | number, diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index 65a5dc02..b9f03f88 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -77,11 +77,8 @@ function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] { ]; } -/** - * Expands a UI table patch into ordered board ops (one request each). - * Schedule/week-only fields are emitted first in a stable order; the composite - * POC op follows, then scalar fields. - */ +// why: Schedule and week fields must go out first, in a stable order, then the composite POC op, +// then scalar fields — each as its own board request. export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] { if (isLocalOnlyTablePatch(patch)) { return []; diff --git a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts index ec712467..fb9d71bf 100644 --- a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts @@ -1,6 +1,7 @@ import type { UpliftSummary, WorkOrderUplift, + WorkOrderUpliftAttachment, WorkOrderUpliftStatus, } from "@/domain/work-orders/types/work-order-uplift"; import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift"; @@ -123,6 +124,53 @@ export function mapUpliftSummary(raw: unknown): UpliftSummary { return summary; } +function mapUpliftAttachment(raw: unknown, index: number): WorkOrderUpliftAttachment | null { + const record = asRecord(raw); + const name = readString(record, "name", "Name", "fileName", "FileName"); + if (name === "") return null; + const idValue = record.id ?? record.Id; + const id = + typeof idValue === "string" || typeof idValue === "number" + ? String(idValue) + : `${name}-${index}`; + return { + id, + name, + url: readString(record, "url", "Url", "fileUrl", "FileUrl"), + }; +} + +function mapUpliftAttachments(raw: unknown): WorkOrderUpliftAttachment[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((item, index) => { + const attachment = mapUpliftAttachment(item, index); + return attachment == null ? [] : [attachment]; + }); +} + +function mapEvidenceAttachment(item: Record): WorkOrderUpliftAttachment[] { + const listed = mapUpliftAttachments( + item.attachments ?? item.Attachments ?? item.documents ?? item.Documents, + ); + if (listed.length > 0) return listed; + const evidenceId = readOptionalId(item, "evidenceDocumentId", "EvidenceDocumentId"); + const name = readString( + item, + "evidenceFileName", + "EvidenceFileName", + "originalFileName", + "OriginalFileName", + ); + if (evidenceId == null && name === "") return []; + return [ + { + id: evidenceId == null ? "evidence" : String(evidenceId), + name: name === "" ? `Document #${evidenceId}` : name, + url: "", + }, + ]; +} + export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift { const item = asRecord(raw); const statusRaw = readString(item, "status", "Status"); @@ -149,7 +197,8 @@ export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift { ), decidedAt: readString(item, "decidedAt", "DecidedAt"), decidedByName: readString(item, "decidedByName", "DecidedByName"), - decisionNote: readString(item, "decisionNote", "DecisionNote"), + decisionNote: readString(item, "decisionNote", "DecisionNote", "revokeReason", "RevokeReason"), + attachments: mapEvidenceAttachment(item), }; } diff --git a/src/domain/work-orders/types/work-order-uplift.ts b/src/domain/work-orders/types/work-order-uplift.ts index aaed9c16..6a213db6 100644 --- a/src/domain/work-orders/types/work-order-uplift.ts +++ b/src/domain/work-orders/types/work-order-uplift.ts @@ -16,6 +16,12 @@ export interface UpliftSummary { amount?: number; } +export interface WorkOrderUpliftAttachment { + id: string; + name: string; + url: string; +} + export interface WorkOrderUplift { id: string | number; status: WorkOrderUpliftStatus; @@ -27,12 +33,14 @@ export interface WorkOrderUplift { decidedAt: string; decidedByName: string; decisionNote: string; + attachments?: WorkOrderUpliftAttachment[]; } export interface CreateWorkOrderUpliftInput { amount: number; notes: string; - attachments?: File[]; + file?: File | null; + evidenceDocumentId?: number; } export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = { diff --git a/src/domain/work-orders/use-cases/submit-work-order-uplift.ts b/src/domain/work-orders/use-cases/submit-work-order-uplift.ts new file mode 100644 index 00000000..fb3a28d9 --- /dev/null +++ b/src/domain/work-orders/use-cases/submit-work-order-uplift.ts @@ -0,0 +1,73 @@ +import type { UpliftEvidenceSnapshot } from "@/domain/work-orders/api/work-order-uplifts-api"; +import type { + CreateWorkOrderUpliftInput, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; + +const POLL_INTERVAL_MS = 1000; +const MAX_POLL_ATTEMPTS = 30; + +type CreateUpliftBody = { + amount: number; + notes: string; + evidenceDocumentId?: number; +}; + +export type SubmitWorkOrderUpliftDeps = { + uploadEvidence: (workOrderId: string | number, file: File) => Promise; + getEvidenceStatus: ( + workOrderId: string | number, + documentId: number, + ) => Promise; + create: (workOrderId: string | number, input: CreateUpliftBody) => Promise; + sleep?: (ms: number) => Promise; +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function scanFailureMessage(scanStatus: "Rejected" | "Timeout"): string { + if (scanStatus === "Rejected") { + return "The evidence file was rejected by the security scan."; + } + return "The evidence file is still being scanned. Try again in a moment."; +} + +async function waitForPassedScan( + workOrderId: string | number, + file: File, + deps: SubmitWorkOrderUpliftDeps, +): Promise { + const pause = deps.sleep ?? sleep; + const uploaded = await deps.uploadEvidence(workOrderId, file); + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { + const document = + attempt === 0 ? uploaded : await deps.getEvidenceStatus(workOrderId, uploaded.id); + if (document.scanStatus === "Passed") { + return uploaded.id; + } + if (document.scanStatus === "Rejected") { + throw new Error(scanFailureMessage("Rejected")); + } + await pause(POLL_INTERVAL_MS); + } + throw new Error(scanFailureMessage("Timeout")); +} + +export async function submitWorkOrderUplift( + workOrderId: string | number, + input: CreateWorkOrderUpliftInput, + deps: SubmitWorkOrderUpliftDeps, +): Promise { + const evidenceDocumentId = input.file + ? await waitForPassedScan(workOrderId, input.file, deps) + : undefined; + return deps.create(workOrderId, { + amount: input.amount, + notes: input.notes, + evidenceDocumentId, + }); +} diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts index 368a6767..bb018d1d 100644 --- a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -9,6 +9,7 @@ import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; import { toast } from "react-toastify"; import { mapHttpStatusToMessage, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error"; import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; +import { submitWorkOrderUplift } from "@/domain/work-orders/use-cases/submit-work-order-uplift"; import type { CreateWorkOrderUpliftInput, WorkOrderUplift, @@ -63,7 +64,12 @@ export function useCreateWorkOrderUplift( return useMutation({ mutationFn: (input: CreateWorkOrderUpliftInput) => - workOrderUpliftsApi.create(workOrderId, input), + submitWorkOrderUplift(workOrderId, input, { + uploadEvidence: (id, file) => workOrderUpliftsApi.uploadEvidence(id, file), + getEvidenceStatus: (id, documentId) => + workOrderUpliftsApi.getEvidenceStatus(id, documentId), + create: (id, body) => workOrderUpliftsApi.create(id, body), + }), onSuccess: () => { invalidateUpliftQueries(queryClient, workOrderId); toast.success("Uplift request created"); diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 998d0605..6485be3f 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -157,8 +157,8 @@ function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): { dateTo?: string; includeDateless?: boolean; } { - // SH-338: server-side unscheduledOnly owns the date-range exemption; the - // client must not fake a Custom window and sends no date params. + // why: SH-338: server-side unscheduledOnly owns the date-range exemption; the client must not fake a + // Custom window and sends no date params. if (filters.unscheduledOnly) { return {}; } diff --git a/src/domain/work-orders/utils/comment-highlight.ts b/src/domain/work-orders/utils/comment-highlight.ts index abc1c2c8..ec30813c 100644 --- a/src/domain/work-orders/utils/comment-highlight.ts +++ b/src/domain/work-orders/utils/comment-highlight.ts @@ -9,7 +9,7 @@ import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-dee * someone else from that moment on, split the same way the feed splits them: comments that * mention the user belong to Mentions, the rest to Recent activity. */ -// Board comment times arrive as UTC without a zone designator; read them as UTC, not local time. +// why: Board comment times arrive as UTC without a zone designator; read them as UTC, not local time. function parseUtc(value: string): number { return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`); } @@ -30,7 +30,7 @@ export function highlightedCommentIds( if (Number.isNaN(createdAt) || createdAt < since || isOwnWorkOrderComment(comment, userId)) { continue; } - // Board comments expose mentions two ways: an encoded `@[id:name]` token in the text, or a + // why: Board comments expose mentions two ways: an encoded `@[id:name]` token in the text, or a // `mentions` id array alongside plain `@Name` text. Treat either as a mention of the user. const mentionIds = new Set(extractMentionIds(comment.commenttext ?? "")); for (const id of comment.mentions ?? []) { diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts index bb868e51..f8a37521 100644 --- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -10,7 +10,7 @@ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const COMPLETION_DOC_FILTERS: CompletionDocFilter[] = ["No", "Yes", "NN"]; -// SH-348: the board region filter (backend#146) accepts these region names. +// why: SH-348: the board region filter (backend#146) accepts these region names. const BOARD_REGION_NAMES = ["East", "Central", "West", "California", "Unmapped/Other"]; const UPLIFT_FILTER_VALUES: UpliftFilterStatus[] = [ diff --git a/src/domain/work-orders/utils/format-board-audit-action.ts b/src/domain/work-orders/utils/format-board-audit-action.ts index e29cbcbd..a70820d2 100644 --- a/src/domain/work-orders/utils/format-board-audit-action.ts +++ b/src/domain/work-orders/utils/format-board-audit-action.ts @@ -130,7 +130,7 @@ function formatGenericAuditFallback(args: { if (oldValue.text) { return `Changed ${label} from ${wrapAuditValue(oldValue)} to "—"`; } - // Never surface a raw audit enum such as "FieldChanged" as the sentence. + // why: Never surface a raw audit enum such as "FieldChanged" as the sentence. if (fieldName && action) return `Updated ${label}`; return "Updated work order"; } diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts index 7135bd01..1f5d9cf9 100644 --- a/src/domain/work-orders/utils/slide-over-draft.ts +++ b/src/domain/work-orders/utils/slide-over-draft.ts @@ -4,13 +4,9 @@ import type { WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; -/** - * Fields compared for dirty state and included in Save patch. - * Only keys that map to board PATCH (or required companions like dispatcherName). - * Primary POC scalars persist via the dedicated POC endpoint; - * tech phone is not board-editable — exclude so Save cannot no-op. - * additionalContacts is board-editable (SH-218 PATCH field). - */ +// why: Primary POC scalars persist on the dedicated POC endpoint, so they stay out of this patch. +// Tech phone is not board-editable; including it would let Save no-op. additionalContacts stays +// because it is a board PATCH field (SH-218). export const SLIDE_OVER_EDIT_KEYS = [ "woNumber", "site", diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 3d8eb132..5e1bca25 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -18,9 +18,8 @@ export function computeIsPastDue(row: WorkOrderTableRow): boolean { if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) { return false; } - // scheduledOn is a calendar date, so compare it to the user's local calendar - // day. A UTC "today" rolls over hours early west of UTC and flags work - // scheduled for today as Past Due every evening. + // why: scheduledOn is a calendar date, so compare it to the user's local calendar day. A UTC "today" + // rolls over hours early west of UTC and flags work scheduled for today as Past Due every evening. return row.scheduledOn.slice(0, 10) < todayIso(); } diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts index 26dd14c2..7a06dcad 100644 --- a/src/domain/work-orders/utils/uplift-display-utils.ts +++ b/src/domain/work-orders/utils/uplift-display-utils.ts @@ -16,17 +16,26 @@ export function formatUpliftMoney(value: number | null | undefined): string { }).format(value); } -const STATUS_LABELS: Record = { +const PILL_LABELS: Record = { + pending: "Pending approval", + approved: "Approved", + auto_approved: "Auto-approved", + rejected: "Rejected", + cancelled: "Cancelled", + revoked: "Revoked", +}; + +const CELL_STATUS_WORD: Record = { pending: "Pending", approved: "Approved", - auto_approved: "Auto", + auto_approved: "Approved", rejected: "Rejected", cancelled: "Cancelled", revoked: "Revoked", }; export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string { - return STATUS_LABELS[status]; + return PILL_LABELS[status]; } export function getUpliftCellLabel(summary: UpliftSummary): string | null { @@ -34,12 +43,15 @@ export function getUpliftCellLabel(summary: UpliftSummary): string | null { return null; } if (summary.pendingCount > 0) { + if (summary.amount != null) { + return `${formatUpliftMoney(summary.amount)} Pending`; + } return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`; } if (summary.primaryStatus) { - const label = formatUpliftStatusLabel(summary.primaryStatus); + const label = CELL_STATUS_WORD[summary.primaryStatus]; if (summary.amount != null) { - return `${label} ${formatUpliftMoney(summary.amount)}`; + return `${formatUpliftMoney(summary.amount)} ${label}`; } return label; } @@ -57,14 +69,13 @@ export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): { case "pending": return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" }; case "approved": - return { background: "var(--status-success-bg, #DCFCE7)", color: "var(--color-success)" }; case "auto_approved": - return { background: "var(--color-primary-tint)", color: "var(--color-primary)" }; + return { background: "var(--status-completed-bg)", color: "var(--status-completed-text)" }; case "rejected": - return { background: "#FEE2E2", color: "#B91C1C" }; + return { background: "var(--status-pastdue-bg)", color: "var(--status-pastdue-text)" }; case "cancelled": case "revoked": - return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" }; + return { background: "var(--status-canceled-bg)", color: "var(--status-canceled-text)" }; default: return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" }; } @@ -74,14 +85,51 @@ export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): return status === "Completed" || status === "Canceled"; } -export function canOpenUpliftsDialog( - summary: UpliftSummary, - status: WorkOrderTableRow["status"], -): boolean { - if (summary.hasUplift || summary.pendingCount > 0) { - return true; +export type UpliftAllowanceHelperTone = "muted" | "success" | "warning"; +export type UpliftAllowanceHelperIcon = "check" | "alert" | null; + +export type UpliftAllowanceHelper = { + text: string; + tone: UpliftAllowanceHelperTone; + tint: boolean; + icon: UpliftAllowanceHelperIcon; +}; + +export function upliftAllowanceHelper( + amount: number, + remainingAllowance: number, +): UpliftAllowanceHelper { + const typed = Number.isFinite(amount) && amount > 0; + if (!typed) { + if (remainingAllowance > 0) { + return { + text: `${formatUpliftMoney(remainingAllowance)} left to auto-approve`, + tone: "muted", + tint: false, + icon: null, + }; + } + return { + text: "Limit used — new uplifts need admin approval", + tone: "warning", + tint: false, + icon: "alert", + }; } - return !isWorkOrderUpliftsReadOnly(status); + if (amount <= remainingAllowance) { + return { + text: `Approves instantly · ${formatUpliftMoney(remainingAllowance - amount)} left after this`, + tone: "success", + tint: false, + icon: "check", + }; + } + return { + text: `Needs admin approval · ${formatUpliftMoney(remainingAllowance)} limit`, + tone: "warning", + tint: true, + icon: "alert", + }; } export function canRevokeWorkOrderUplift( @@ -89,7 +137,7 @@ export function canRevokeWorkOrderUplift( currentUserId: string | number | null | undefined, ): boolean { if (uplift.status !== "auto_approved") { - // SH-214/SH-212: admin-approved revoke lives on Uplift Approvals, not the WO dialog. + // why: SH-214/SH-212 keep admin-approved revoke on Uplift Approvals, not this work order panel. return false; } return ( @@ -128,3 +176,90 @@ export function getRemainingUpliftAllowance( .reduce((sum, uplift) => sum + uplift.amount, 0); return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed); } + +function displayActor(name: string): string { + return name.trim() === "" ? "—" : name; +} + +export function formatUpliftAbsoluteDate(iso: string): string { + if (iso.trim() === "") return "—"; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +export function formatUpliftRelativeTime(iso: string, nowMs: number): string { + if (iso.trim() === "") return "—"; + const timestamp = new Date(iso).getTime(); + if (Number.isNaN(timestamp)) return "—"; + const minutes = Math.floor((nowMs - timestamp) / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +type UpliftMetaSource = Pick< + WorkOrderUplift, + "status" | "requestedAt" | "requestedByName" | "decidedAt" | "decidedByName" +>; + +export function formatUpliftCardMeta(uplift: UpliftMetaSource, nowMs: number): string { + const requester = displayActor(uplift.requestedByName); + const decider = displayActor(uplift.decidedByName); + if (uplift.status === "pending") { + return `${requester} · ${formatUpliftRelativeTime(uplift.requestedAt, nowMs)}`; + } + if (uplift.status === "auto_approved") { + return `Within allowance · ${requester} · ${formatUpliftAbsoluteDate(uplift.requestedAt)}`; + } + if (uplift.status === "approved") { + return `Approved by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`; + } + if (uplift.status === "rejected") { + return `Rejected by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`; + } + if (uplift.status === "revoked") { + return `Revoked by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`; + } + const when = uplift.decidedAt.trim() === "" ? uplift.requestedAt : uplift.decidedAt; + return `Cancelled by ${requester} · ${formatUpliftAbsoluteDate(when)}`; +} + +export function isVoidUpliftStatus(status: WorkOrderUpliftStatus): boolean { + return status === "rejected" || status === "cancelled" || status === "revoked"; +} + +export type UpliftCellVisual = + | { kind: "pending"; amount: string | null; statusLabel: "Pending" } + | { kind: "approved"; amount: string | null; statusLabel: "Approved" } + | { kind: "rejected"; amount: string | null; statusLabel: "Rejected" } + | { kind: "plain"; label: string }; + +export function getUpliftCellVisual(summary: UpliftSummary): UpliftCellVisual | null { + const label = getUpliftCellLabel(summary); + if (label == null) return null; + if (summary.pendingCount > 0) { + return { + kind: "pending", + amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null, + statusLabel: "Pending", + }; + } + if (summary.primaryStatus === "approved" || summary.primaryStatus === "auto_approved") { + return { + kind: "approved", + amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null, + statusLabel: "Approved", + }; + } + if (summary.primaryStatus === "rejected") { + return { + kind: "rejected", + amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null, + statusLabel: "Rejected", + }; + } + return { kind: "plain", label }; +} diff --git a/src/test/api/api-formdata-content-type.test.ts b/src/test/api/api-formdata-content-type.test.ts index 94312aba..170f972b 100644 --- a/src/test/api/api-formdata-content-type.test.ts +++ b/src/test/api/api-formdata-content-type.test.ts @@ -1,6 +1,6 @@ -// @vitest-environment node -// Node's fetch/FormData/Request follow the WHATWG rules a browser uses to derive the multipart +// why: Node's fetch/FormData/Request follow the WHATWG rules a browser uses to derive the multipart // Content-Type; jsdom's FormData does not, which is how a header-less upload once passed here. +// @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; type Captured = { method: string; contentType: string | null; body: string }; @@ -16,7 +16,7 @@ beforeEach(() => { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - // Serialize exactly what would go on the wire. + // why: Serialize exactly what would go on the wire. const request = new Request(input, init); captured.push({ method: request.method, diff --git a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx index 37c3632e..b219e880 100644 --- a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx +++ b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx @@ -18,8 +18,8 @@ vi.mock("react-router", async (importOriginal) => ({ useNavigate: () => navigate, })); -// An Admin has `viewAllDispatchersOnDashboard`, so the scope picker and the -// dispatcher tables render (SH-336). +// why: An Admin has `viewAllDispatchersOnDashboard`, so the scope picker and the dispatcher tables +// render (SH-336). vi.mock("@/providers/auth-context", async (importOriginal) => ({ ...(await importOriginal()), useAuthContext: () => ({ user: authUser.current }), @@ -141,7 +141,7 @@ describe("DashboardPage", () => { expect(screen.queryByRole("button", { name: "All dispatchers" })).toBeNull(); expect(screen.queryByText("Dispatcher Workload")).toBeNull(); expect(screen.queryByText("Dispatcher Performance")).toBeNull(); - // Their scope is server-scoped, so the tables are never even queried. + // why: Their scope is server-scoped, so the tables are never even queried. expect(workloadMock).toHaveBeenCalledWith("this-week", 1, null, false); }); @@ -204,7 +204,7 @@ describe("DashboardPage", () => { it("shows the documented empty states when datasets are empty", () => { renderDashboard(); - // Region, Status Distribution, and Trend share this empty copy. + // why: Region, Status Distribution, and Trend share this empty copy. expect(screen.getAllByText("No work orders match the current filters.")).toHaveLength(3); expect(screen.getAllByText("No dispatchers are set up yet.")).toHaveLength(2); expect(screen.getByText("No active vendor has any work orders yet.")).toBeInTheDocument(); @@ -226,7 +226,7 @@ describe("DashboardPage", () => { renderDashboard(); - // 23 dispatchers at 10/page = 3 pages; selecting page 2 re-queries with page 2. + // why: 23 dispatchers at 10/page = 3 pages; selecting page 2 re-queries with page 2. fireEvent.click(screen.getByRole("button", { name: "Go to page 2" })); const requestedPages = workloadMock.mock.calls.map((call) => call[1]); diff --git a/src/test/app/(protected)/dashboard/dispatcher-table-pager.test.tsx b/src/test/app/(protected)/dashboard/dispatcher-table-pager.test.tsx index 4e011bd6..98ffde8b 100644 --- a/src/test/app/(protected)/dashboard/dispatcher-table-pager.test.tsx +++ b/src/test/app/(protected)/dashboard/dispatcher-table-pager.test.tsx @@ -29,7 +29,7 @@ describe("DispatcherTablePager", () => { />, ); - // 23 rows at 10/page = 3 pages. + // why: 23 rows at 10/page = 3 pages. expect(screen.getByRole("button", { name: "Go to page 3" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Go to page 4" })).toBeNull(); diff --git a/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx index efb56316..4c8e4f46 100644 --- a/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx +++ b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx @@ -1,8 +1,8 @@ import { screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import PmSchedulesListPage from "@/app/(protected)/pmschedules/index"; -// Static import: a dynamic import inside the test transforms the edit page's -// module graph against the 5s test budget, which times out under load. +// why: Static import: a dynamic import inside the test transforms the edit page's module graph against +// the 5s test budget, which times out under load. import EditPage from "@/app/(protected)/pmschedules/edit/[id]"; import { renderWithProviders } from "@/test/test-utils"; diff --git a/src/test/app/(protected)/settings/services-registry-view.test.tsx b/src/test/app/(protected)/settings/services-registry-view.test.tsx index a696f9eb..d716d757 100644 --- a/src/test/app/(protected)/settings/services-registry-view.test.tsx +++ b/src/test/app/(protected)/settings/services-registry-view.test.tsx @@ -130,7 +130,7 @@ describe("ServicesRegistryView", () => { await userEvent.type(screen.getByRole("textbox", { name: "Service Name" }), "Panel Swap"); await userEvent.click(screen.getByRole("combobox", { name: "Trade" })); - // Canonical names are offered; legacy DropdownOptions seeds ("Plumbing") are not. + // why: Canonical names are offered; legacy DropdownOptions seeds ("Plumbing") are not. expect(screen.getByRole("option", { name: "Plumbing & Water Systems" })).toBeInTheDocument(); expect(screen.queryByRole("option", { name: "Plumbing" })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("option", { name: "Electrical" })); diff --git a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx index aed0bfb6..ef431201 100644 --- a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx @@ -135,7 +135,7 @@ describe("UpliftDetailModal", () => { const request = section("Uplift request"); expect(fieldValue(request, "Requested By")).toBe("Gateway"); - // The instant renders in the viewer's zone, so the calendar day is derived, not fixed. + // why: The instant renders in the viewer's zone, so the calendar day is derived, not fixed. const localDay = new Date(baseItem.requestedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index 031b589e..8a2815ca 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -346,8 +346,8 @@ describe("useVendorRosterForm legacy stored Maps URL", () => { wrapper: makeWrapper(createClient()), }); - // Exercise the real resolver: while the form schema validated the hidden - // loaded URL, this submit never reached the save path. + // why: Exercise the real resolver: while the form schema validated the hidden loaded URL, this submit + // never reached the save path. await act(async () => { await result.current.handleSubmit(result.current.submit)({ preventDefault: () => undefined, diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index 644fc329..e57a1f41 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -49,17 +49,16 @@ function renderCreateModal() { }); } -// delay: null skips user-event's per-keystroke setTimeout yields, which only -// add real-timer latency here. +// why: delay: null skips user-event's per-keystroke setTimeout yields, which only add real-timer +// latency here. let user = userEvent.setup({ delay: null }); async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), { target: { value: name }, }); - // SH-271 made the structured address required. These tests are about the - // phone-or-email rule, so the address is filled to keep the form's only - // outstanding problem the one under test. + // why: SH-271 made the structured address required. These tests are about the phone-or-email rule, so + // the address is filled to keep the form's only outstanding problem the one under test. fillRequiredAddress(); await user.click(screen.getByRole("button", { name: "Add Vendor" })); } @@ -78,10 +77,9 @@ function fillRequiredAddress({ pickVendorState(state); } -// Each test mounts the full MUI dialog (Autocompletes, 50-option State list) -// and costs ~1s of CPU when idle; the first also pays JIT warm-up. Under -// parallel agent load the 5s default is exceeded by CPU starvation alone, -// matching the 10s budgets already used by vendors-list.test.tsx. +// why: Each test mounts the full MUI dialog (Autocompletes, 50-option State list) and costs ~1s of CPU +// when idle; the first also pays JIT warm-up. Under parallel agent load the 5s default is exceeded +// by CPU starvation alone, matching the 10s budgets already used by vendors-list.test.tsx. describe("VendorCreateModal validation", { timeout: 15_000 }, () => { beforeEach(() => { user = userEvent.setup({ delay: null }); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index 0e5ea3eb..b877ece1 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -51,8 +51,8 @@ function rosterWith(technicians: Array>) { name: "Gateway Plumbing", companyPhone: "(314) 555-0100", email: "dispatch@gateway.test", - // SH-271: Street/City/State are required, so a saved roster carries them. - // Tests that need the incomplete case blank them explicitly. + // why: SH-271: Street/City/State are required, so a saved roster carries them. Tests that need the + // incomplete case blank them explicitly. address: "6735 Trippel Rd", city: "Theodore", state: "AL", @@ -68,8 +68,8 @@ function rosterWith(technicians: Array>) { }; } -// delay: null skips user-event's per-keystroke setTimeout yields, which only -// add real-timer latency here. +// why: delay: null skips user-event's per-keystroke setTimeout yields, which only add real-timer +// latency here. let user = userEvent.setup({ delay: null }); beforeEach(() => { @@ -77,9 +77,9 @@ beforeEach(() => { user = userEvent.setup({ delay: null }); }); -// The edit-mode drawer is a heavy MUI render and the first test pays JIT -// warm-up; under parallel agent load the 5s default is exceeded by CPU -// starvation alone, matching the 10s budgets in vendors-list.test.tsx. +// why: The edit-mode drawer is a heavy MUI render and the first test pays JIT warm-up; under parallel +// agent load the 5s default is exceeded by CPU starvation alone, matching the 10s budgets in +// vendors-list.test.tsx. describe("VendorDetailDrawer selected-technician display", { timeout: 15_000 }, () => { it.each([ { removedPosition: 1, remainingId: 2, remainingName: "Beth" }, @@ -358,8 +358,8 @@ describe("VendorDetailDrawer design parity", () => { }); it("shows Street, City and State as separate rows with no stored-URL Google Maps row", () => { - // SH-271 removed the "Open in Google Maps" row and the stored URL behind - // it; the location is derived from the structured address instead. + // why: SH-271 removed the "Open in Google Maps" row and the stored URL behind it; the location is + // derived from the structured address instead. useVendorCompanyRoster.mockReturnValue( rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]), ); @@ -393,7 +393,7 @@ describe("VendorDetailDrawer design parity", () => { }); it("renders no map preview and no link until the address is complete", () => { - // A preview built from half an address points somewhere misleading. + // why: A preview built from half an address points somewhere misleading. const roster = rosterWith([ { id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }, ]); diff --git a/src/test/app/(protected)/workorders/comp-doc-signed-pdf-upload.test.tsx b/src/test/app/(protected)/workorders/comp-doc-signed-pdf-upload.test.tsx index 3f22dc25..4d65c0d8 100644 --- a/src/test/app/(protected)/workorders/comp-doc-signed-pdf-upload.test.tsx +++ b/src/test/app/(protected)/workorders/comp-doc-signed-pdf-upload.test.tsx @@ -16,9 +16,9 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }), })); -// A completion-doc upload that settles successfully. The backend has already set -// DocStatus=Yes and bumped the work-order row version inside the same transaction, -// so any client follow-up patch would carry a stale version. +// why: A completion-doc upload that settles successfully. The backend has already set DocStatus=Yes and +// bumped the work-order row version inside the same transaction, so any client follow-up patch +// would carry a stale version. vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ useUploadCompletionDoc: () => ({ mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise }) => { @@ -126,8 +126,8 @@ describe("CompDocDialog signed PDF upload (SH-380)", () => { } it("does not re-run the docStatus persist after a successful upload", async () => { - // The server already marked the document generated; a client docStatus patch here would - // carry the pre-upload row version and 409. It must not run. + // why: The server already marked the document generated; a client docStatus patch here would carry the + // pre-upload row version and 409. It must not run. const onSave: SaveHandler = vi.fn(() => Promise.reject(new Error("The record was modified by another user. Refresh and retry.")), ); diff --git a/src/test/app/(protected)/workorders/slide-over-comment-highlight.test.tsx b/src/test/app/(protected)/workorders/slide-over-comment-highlight.test.tsx index 8b5c08ba..23ba5545 100644 --- a/src/test/app/(protected)/workorders/slide-over-comment-highlight.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-comment-highlight.test.tsx @@ -25,7 +25,7 @@ function comment( }; } -// Board comment times arrive without a zone designator and are UTC. +// why: Board comment times arrive without a zone designator and are UTC. const thread = [ comment(1, "omar", "2026-09-18T08:00:00", "Earlier note, already seen"), comment(2, "omar", "2026-09-18T09:30:00", "Vendor is running late"), diff --git a/src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx b/src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx index a92bc22f..cd1decd5 100644 --- a/src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-signed-pdf-upload.test.tsx @@ -19,8 +19,8 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ }), })); -// A completion-doc upload that settles successfully. The mock forwards any onSuccess the -// caller passes; the fix means the slide-over passes none (no follow-up docStatus patch). +// why: A completion-doc upload that settles successfully. The mock forwards any onSuccess the caller +// passes; the fix means the slide-over passes none (no follow-up docStatus patch). vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), @@ -96,7 +96,7 @@ describe("WorkOrderSlideOver signed PDF upload (SH-380)", () => { it("does not issue a docStatus patch or surface an error after a successful upload", async () => { const row = baseRow(); - // A follow-up docStatus patch would carry the pre-upload version and reject; assert none runs. + // why: A follow-up docStatus patch would carry the pre-upload version and reject; assert none runs. const onSave = vi.fn( ( _id: string | number, diff --git a/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx index 4ca3e8d8..3ce7d4b6 100644 --- a/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx @@ -120,8 +120,8 @@ function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number) }; } -// Role and text queries over the whole board scan every row cell; scoping them -// to the pagination footer keeps each waitFor poll cheap on a loaded machine. +// why: Role and text queries over the whole board scan every row cell; scoping them to the pagination +// footer keeps each waitFor poll cheap on a loaded machine. function pagination() { return within(screen.getByTestId("wo-day-pagination")); } diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-server-owned.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-server-owned.test.tsx index ecd9f64f..f1fb9142 100644 --- a/src/test/app/(protected)/workorders/wo-table-unscheduled-server-owned.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-server-owned.test.tsx @@ -162,10 +162,10 @@ describe("unscheduled advanced search is server-owned (SH-338)", () => { }); it("sends unscheduledOnly=true with no date window and pages server rows/totals", async () => { - // Server owns totals: page 0 returns 3 rows while totalCount spans 2 pages. + // why: Server owns totals: page 0 returns 3 rows while totalCount spans 2 pages. const pageZero = [ makeRow({ id: "wo-0", woNumber: "WO-0", scheduledOn: "" }), - // Server owns row filtering: a dated row on the page must still render. + // why: Server owns row filtering: a dated row on the page must still render. makeRow({ id: "wo-1", woNumber: "WO-1", scheduledOn: "2026-08-17" }), makeRow({ id: "wo-2", woNumber: "WO-2", scheduledOn: "" }), ]; diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx index 1afe8888..eb32a4cf 100644 --- a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx @@ -1,8 +1,10 @@ -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { WorkOrderSlideOverTabNav } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav"; +import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab"; import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell"; +import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog"; import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item"; -import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; import { renderWithProviders } from "@/test/test-utils"; @@ -22,6 +24,7 @@ const pendingUplift: WorkOrderUplift = { const mockState = vi.hoisted(() => ({ uplifts: [] as WorkOrderUplift[], + cancel: vi.fn(), })); const baseRow: WorkOrderTableRow = { @@ -81,12 +84,12 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({ refetch: vi.fn(), }), useCreateWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), - useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + useCancelWorkOrderUplift: () => ({ mutate: mockState.cancel, isPending: false }), useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), })); describe("UpliftCell affordances", () => { - it("renders pending pill and opens handler when onOpen is provided", () => { + it("renders pending label and opens handler when onOpen is provided", () => { const onOpen = vi.fn(); renderWithProviders( @@ -98,6 +101,30 @@ describe("UpliftCell affordances", () => { expect(onOpen).toHaveBeenCalledTimes(1); }); + it("shows Add and opens the slide-over shortcut when the work order has no uplift", () => { + const onOpen = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /add uplift/i })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("keeps a closed work order with no uplift clickable", () => { + const onOpen = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /view uplifts/i })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + it("shows read-only dash when onOpen is omitted", () => { renderWithProviders(, { withAuth: false, @@ -108,42 +135,99 @@ describe("UpliftCell affordances", () => { }); }); -describe("WorkOrderUpliftsDialog affordances", () => { - it("hides create form when a pending request already exists", () => { - mockState.uplifts = [pendingUplift]; - - renderWithProviders(, { - withAuth: true, +describe("slide-over section order", () => { + it("places Uplifts before Extra Docs and keeps Audit Log last", () => { + renderWithProviders(, { + withAuth: false, }); - expect(screen.getByRole("heading", { name: /uplifts — wo wo-42/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /cancel pending/i })).toBeInTheDocument(); - expect(screen.getByText(/cancel the pending request to file a new one/i)).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument(); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "Info", + "Comments", + "Completion Doc", + "Uplifts", + "Extra Docs", + "Audit Log", + ]); }); +}); - it("shows create form with remaining allowance when no pending request exists", () => { - mockState.uplifts = []; - - renderWithProviders(, { - withAuth: true, - }); - - expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument(); - expect(screen.getByText(/remaining auto-approval: \$500/i)).toBeInTheDocument(); - }); - - it("hides create and cancel actions when work order is completed", () => { +describe("SlideOverUpliftsTab affordances", () => { + it("hides the request form when a pending request already exists", () => { mockState.uplifts = [pendingUplift]; renderWithProviders( - , + , { withAuth: true }, ); - expect(screen.getByText(/uplifts are read-only/i)).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /cancel pending/i })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel request/i })).toBeInTheDocument(); + expect(screen.getByText(/pending admin approval/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /request uplift/i })).not.toBeInTheDocument(); + }); + + it("shows the request form and optional attachments when no pending request exists", () => { + mockState.uplifts = []; + + renderWithProviders( + , + { withAuth: true }, + ); + + expect(screen.getByRole("button", { name: /request uplift/i })).toBeDisabled(); + expect( + screen.getByRole("button", { name: /drag a file here or click to browse/i }), + ).toBeEnabled(); + expect(screen.getByText(/\$500 left to auto-approve/i)).toBeInTheDocument(); + expect(screen.getByText(/no uplifts on this work order/i)).toBeInTheDocument(); + }); + + it("hides request and cancel actions when the work order is completed", () => { + mockState.uplifts = [pendingUplift]; + + renderWithProviders( + , + { withAuth: true }, + ); + + expect(screen.getByText(/uplifts can no longer be requested/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /request uplift/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /cancel request/i })).not.toBeInTheDocument(); + }); + + it("confirms before cancelling a pending request", () => { + mockState.uplifts = [pendingUplift]; + mockState.cancel.mockClear(); + + renderWithProviders( + , + { withAuth: true }, + ); + + fireEvent.click(screen.getByRole("button", { name: /cancel request/i })); + expect(mockState.cancel).not.toHaveBeenCalled(); + expect(screen.getByRole("heading", { name: "Cancel uplift request?" })).toBeInTheDocument(); + + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: /cancel request/i }), + ); + expect(mockState.cancel).toHaveBeenCalledWith(pendingUplift.id, expect.any(Object)); }); }); @@ -175,4 +259,84 @@ describe("WorkOrderUpliftListItem revoke affordances", () => { expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument(); }); + + it("shows the within-allowance meta on an auto-approved uplift", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Auto-approved")).toBeInTheDocument(); + expect(screen.getByText("$60")).toBeInTheDocument(); + expect(screen.getByText("Within allowance · Adam Moussa · Sep 24, 2026")).toBeInTheDocument(); + }); + + it("shows a linked evidence document on the card", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: "quote.png" })).toBeInTheDocument(); + }); + + it("keeps revoke visible and disabled when the work order is closed", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /revoke/i })).toBeDisabled(); + }); +}); + +describe("RevokeUpliftDialog copy", () => { + it("explains that the amount returns to the allowance", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("heading", { name: "Revoke this uplift?" })).toBeInTheDocument(); + expect( + screen.getByText( + "$60 goes back to your auto-approval allowance. This is recorded in the log.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Reason (optional)")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Wrong amount, wrong quote...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /revoke/i })).toBeEnabled(); + }); }); diff --git a/src/test/domain/dashboard/work-order-drilldown-links.test.ts b/src/test/domain/dashboard/work-order-drilldown-links.test.ts index 97be821c..5fd9bc4c 100644 --- a/src/test/domain/dashboard/work-order-drilldown-links.test.ts +++ b/src/test/domain/dashboard/work-order-drilldown-links.test.ts @@ -26,16 +26,16 @@ describe("work-order drilldown links", () => { expect(search.get("dateFrom")).toBe("2026-09-16"); expect(search.get("dateTo")).toBe("2026-09-17"); expect(search.get("aveta")).toBe("1"); - // `docs=No` is the completion-document status, not the Aveta document, so it - // must not be part of the Aveta Pending drilldown (SH-294 / backend#138). + // why: `docs=No` is the completion-document status, not the Aveta document, so it must not be part of + // the Aveta Pending drilldown (SH-294 / backend#138). expect(search.get("docs")).toBeNull(); expect(statuses).toContain("Scheduled"); expect(statuses).not.toContain("Completed"); }); it("carries the range into status drilldowns", () => { - // "today" resolves against the business clock, so pin it (midday EDT on - // 2026-09-16 → 2026-09-16 in America/New_York) to keep the test deterministic. + // why: "today" resolves against the business clock, so pin it (midday EDT on 2026-09-16 → 2026-09-16 in + // America/New_York) to keep the test deterministic. vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-16T16:00:00Z")); diff --git a/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx b/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx index a91312bc..a68f191c 100644 --- a/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx +++ b/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx @@ -69,8 +69,8 @@ describe("useVendorPortalSession", () => { const sessionSpy = vi .spyOn(vendorPortalApi, "session") .mockResolvedValue({ companyName: "Acme Vendor" }); - // staleTime: Infinity isolates cache-reuse from React Query's default - // background-refetch-on-mount behavior, which is a separate concern. + // why: staleTime: Infinity isolates cache-reuse from React Query's default background-refetch-on-mount + // behavior, which is a separate concern. const queryClient = createTestQueryClient(); queryClient.setDefaultOptions({ queries: { retry: false, gcTime: 0, staleTime: Infinity } }); const wrapper = wrapperFor(queryClient); @@ -111,8 +111,8 @@ describe("useVendorPortalSession", () => { const sessionSpy = vi .spyOn(vendorPortalApi, "session") .mockRejectedValue(new Error("Invalid link")); - // Uses the real app query client (default retry: 1) instead of the test - // client, so this only passes if the hook opts out of retries itself. + // why: Uses the real app query client (default retry: 1) instead of the test client, so this only + // passes if the hook opts out of retries itself. const queryClient = createAppQueryClient(); const { result } = renderHook(() => useVendorPortalSession("token-1"), { @@ -125,8 +125,8 @@ describe("useVendorPortalSession", () => { it("does not show the global error toast, since the layout renders its own inline error UI", async () => { vi.spyOn(vendorPortalApi, "session").mockRejectedValue(new Error("Invalid link")); - // Uses the real app query client so the QueryCache's global onError - // handler actually runs and would toast unless suppressed. + // why: Uses the real app query client so the QueryCache's global onError handler actually runs and + // would toast unless suppressed. const queryClient = createAppQueryClient(); const { result } = renderHook(() => useVendorPortalSession("token-1"), { diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts index 3f4beef9..d71e0f8f 100644 --- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -84,8 +84,8 @@ describe("vendor roster mapper", () => { expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" }), ]); expect(payload).not.toHaveProperty("tradeSpecialties"); - // The stored Maps URL is deprecated: a legacy value reaching the mapper - // (from stale callers or old records) must not be emitted to the backend. + // why: The stored Maps URL is deprecated: a legacy value reaching the mapper (from stale callers or old + // records) must not be emitted to the backend. expect(payload).not.toHaveProperty("googleMapsUrl"); }); diff --git a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts index da7bcff4..87f24795 100644 --- a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts @@ -13,8 +13,8 @@ const baseCompany = { name: "Gateway Plumbing", companyPhone: "(314) 555-0100", email: "dispatch@gateway.test", - // SH-271 made the structured address required, so a company that is meant to - // parse cleanly has to carry one. + // why: SH-271 made the structured address required, so a company that is meant to parse cleanly has to + // carry one. address: "6735 Trippel Rd", city: "Theodore", state: "AL", @@ -114,9 +114,9 @@ describe("vendorCompanyRosterSchema", () => { }); it("tolerates a legacy stored Maps URL instead of failing HTTPS validation", () => { - // Legacy records carry a stored googleMapsUrl — often plain HTTP. The form - // no longer holds the field, so leftover values loaded from old data must - // neither fail validation nor re-enter the parsed output. + // why: Legacy records carry a stored googleMapsUrl — often plain HTTP. The form no longer holds the + // field, so leftover values loaded from old data must neither fail validation nor re-enter the + // parsed output. const result = vendorCompanyRosterSchema.safeParse({ ...baseCompany, googleMapsUrl: "http://maps.google.com/legacy", diff --git a/src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts b/src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts index 507c52c3..6c548503 100644 --- a/src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts +++ b/src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts @@ -59,7 +59,7 @@ describe("addTechniciansToCompany (SH-365)", () => { expect(apiPatch).toHaveBeenCalledTimes(1); const [path, body] = apiPatch.mock.calls[0] ?? []; expect(path).toBe("vendor-company-roster/7"); - // No companyFields: an existing company's phone/email/address are never sent or blanked. + // why: No companyFields: an existing company's phone/email/address are never sent or blanked. expect(body).toEqual({ rowVersion: "AAAAAAAAB9E=", addTechnicians: [ diff --git a/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts index 599ae34e..941e71a6 100644 --- a/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts +++ b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts @@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const apiGet = vi.fn(); const apiPost = vi.fn(); +const apiPostForm = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), apiPost: (...args: unknown[]) => apiPost(...args), + apiPostForm: (...args: unknown[]) => apiPostForm(...args), })); import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; @@ -14,6 +16,44 @@ describe("workOrderUpliftsApi", () => { beforeEach(() => { apiGet.mockReset(); apiPost.mockReset(); + apiPostForm.mockReset(); + }); + + it("creates an uplift with amount and notes when no evidence id is present", async () => { + apiPost.mockResolvedValueOnce({ id: 9, status: "pending", amount: 25, notes: "Leak" }); + + await workOrderUpliftsApi.create(7, { amount: 25, notes: "Leak" }); + + expect(apiPost).toHaveBeenCalledWith("workorders/7/uplifts", { amount: 25, notes: "Leak" }); + }); + + it("includes evidenceDocumentId when the scanned document id is present", async () => { + apiPost.mockResolvedValueOnce({ id: 9, status: "pending", amount: 25, notes: "Leak" }); + + await workOrderUpliftsApi.create(7, { amount: 25, notes: "Leak", evidenceDocumentId: 22 }); + + expect(apiPost).toHaveBeenCalledWith("workorders/7/uplifts", { + amount: 25, + notes: "Leak", + evidenceDocumentId: 22, + }); + }); + + it("uploads one evidence file and reads the pending document id", async () => { + apiPostForm.mockResolvedValueOnce({ + data: { id: 22, scanStatus: "Pending", originalFileName: "quote.pdf" }, + }); + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + + await expect(workOrderUpliftsApi.uploadEvidence(7, quote)).resolves.toEqual({ + id: 22, + scanStatus: "Pending", + originalFileName: "quote.pdf", + }); + + expect(apiPostForm).toHaveBeenCalledWith("workorders/7/uplift-evidence", expect.any(FormData)); + const body = apiPostForm.mock.calls[0]?.[1] as FormData; + expect(body.get("file")).toBe(quote); }); it("propagates a real API list failure instead of returning mock uplifts", async () => { diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index d6766be8..f935c556 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -1128,10 +1128,10 @@ describe("workOrdersApi.getMediaContent", () => { API_PATHS.workOrder.mediaContent(10, 12), expect.objectContaining({ throwHttpErrors: false }), ); - // Regression guard (SH-382): a credentialed cross-origin fetch is rejected by the browser - // against the API's `Access-Control-Allow-Origin: *`, so the content request must not opt - // into credentials mode — otherwise the document never opens. JSDOM cannot enforce CORS, so - // this asserts the request shape rather than reproducing the block. + // why: Regression guard (SH-382): a credentialed cross-origin fetch is rejected by the browser against + // the API's `Access-Control-Allow-Origin: *`, so the content request must not opt into credentials + // mode — otherwise the document never opens. JSDOM cannot enforce CORS, so this asserts the + // request shape rather than reproducing the block. const [, options] = apiGetFn.mock.calls[0] as [string, Record]; expect(options).not.toHaveProperty("credentials"); }); diff --git a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts index 8ff30f72..c9c16563 100644 --- a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts +++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts @@ -29,8 +29,8 @@ describe("parseDashboardDrilldownParams", () => { expect(filters).not.toBeNull(); expect(filters?.avetaOnly).toBe(true); - // The Aveta drilldown no longer sends `docs=No` (completion-doc status), so - // no completion-document filter is applied. + // why: The Aveta drilldown no longer sends `docs=No` (completion-doc status), so no completion-document + // filter is applied. expect(filters?.docs.size).toBe(0); expect(filters?.statuses).not.toBeNull(); expect([...(filters?.statuses ?? [])]).not.toContain("Completed"); diff --git a/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts index d765c4d5..f52c61f6 100644 --- a/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts @@ -59,6 +59,29 @@ describe("mapUpliftSummary", () => { "dispatcher-1", ); }); + + it("maps filed attachments when the list payload includes them", () => { + expect( + mapWorkOrderUplift({ + attachments: [{ id: "file-1", name: "quote.png", url: "https://files.example/quote.png" }], + }).attachments, + ).toEqual([{ id: "file-1", name: "quote.png", url: "https://files.example/quote.png" }]); + }); + + it("maps the linked evidence document when the list payload has no attachment array", () => { + expect( + mapWorkOrderUplift({ + evidenceDocumentId: 22, + evidenceFileName: "d502d917_Instore_logo_white_720x180.png", + }).attachments, + ).toEqual([ + { + id: "22", + name: "d502d917_Instore_logo_white_720x180.png", + url: "", + }, + ]); + }); }); describe("uplift display utils", () => { @@ -79,7 +102,7 @@ describe("uplift display utils", () => { primaryStatus: "approved", amount: 1200, }), - ).toBe("Approved $1,200"); + ).toBe("$1,200 Approved"); }); it("hides Pending/Cancelled when hasUplift is false", () => { diff --git a/src/test/domain/work-orders/use-cases/submit-work-order-uplift.test.ts b/src/test/domain/work-orders/use-cases/submit-work-order-uplift.test.ts new file mode 100644 index 00000000..ff410055 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/submit-work-order-uplift.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import { submitWorkOrderUplift } from "@/domain/work-orders/use-cases/submit-work-order-uplift"; +import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; + +const created = { id: 9, status: "pending", amount: 25, notes: "Leak" } as WorkOrderUplift; + +function deps(overrides: Partial[2]> = {}) { + return { + uploadEvidence: vi.fn(), + getEvidenceStatus: vi.fn(), + create: vi.fn().mockResolvedValue(created), + sleep: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe("submitWorkOrderUplift", () => { + it("creates without an evidence id when no file is attached", async () => { + const collaborators = deps(); + + await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: null }, collaborators); + + expect(collaborators.uploadEvidence).not.toHaveBeenCalled(); + expect(collaborators.create).toHaveBeenCalledWith(7, { + amount: 25, + notes: "Leak", + evidenceDocumentId: undefined, + }); + }); + + it("creates with the document id after the scan passes", async () => { + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + const collaborators = deps({ + uploadEvidence: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Pending" }), + getEvidenceStatus: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Passed" }), + }); + + await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators); + + expect(collaborators.uploadEvidence).toHaveBeenCalledWith(7, quote); + expect(collaborators.create).toHaveBeenCalledWith(7, { + amount: 25, + notes: "Leak", + evidenceDocumentId: 22, + }); + }); + + it("does not create the uplift when the scan rejects the file", async () => { + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + const collaborators = deps({ + uploadEvidence: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Rejected" }), + }); + + await expect( + submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators), + ).rejects.toThrow(/rejected/i); + expect(collaborators.create).not.toHaveBeenCalled(); + }); + + it("does not create the uplift when the scan does not finish", async () => { + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + const collaborators = deps({ + uploadEvidence: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Pending" }), + getEvidenceStatus: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Pending" }), + }); + + await expect( + submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators), + ).rejects.toThrow(/still being scanned/i); + expect(collaborators.create).not.toHaveBeenCalled(); + expect(collaborators.sleep).toHaveBeenCalledTimes(30); + }); +}); diff --git a/src/test/domain/work-orders/utils/comment-highlight.test.ts b/src/test/domain/work-orders/utils/comment-highlight.test.ts index 735d8387..d0b8aeea 100644 --- a/src/test/domain/work-orders/utils/comment-highlight.test.ts +++ b/src/test/domain/work-orders/utils/comment-highlight.test.ts @@ -30,7 +30,7 @@ describe("highlightedCommentIds", () => { const mentionHit = highlightedCommentIds([comment], { since: SINCE, mentions: true }, "user-1"); expect(mentionHit.has("m1")).toBe(true); - // The same comment must NOT tint under Recent activity (comments bucket). + // why: The same comment must NOT tint under Recent activity (comments bucket). const commentBucket = highlightedCommentIds( [comment], { since: SINCE, mentions: false }, diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index cb7b10e5..2cc08b3d 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -578,7 +578,7 @@ describe("computeIsPastDue", () => { describe("computeIsPastDue across the local/UTC day boundary", () => { beforeEach(() => { - // UTC-3 with no DST: local evening is already the next day in UTC. + // why: UTC-3 with no DST: local evening is already the next day in UTC. vi.stubEnv("TZ", "America/Sao_Paulo"); vi.useFakeTimers({ toFake: ["Date"] }); }); diff --git a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts index af91feeb..2984e96b 100644 --- a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts +++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; import { canRevokeWorkOrderUplift, + formatUpliftCardMeta, getRemainingUpliftAllowance, + getUpliftCellLabel, + getUpliftCellVisual, hasOpenWorkOrderUplift, + upliftAllowanceHelper, upliftRevokeRequiresReason, } from "@/domain/work-orders/utils/uplift-display-utils"; @@ -87,3 +91,101 @@ describe("SH-196 allowance helpers", () => { expect(hasOpenWorkOrderUplift([{ status: "auto_approved" }])).toBe(false); }); }); + +describe("upliftAllowanceHelper", () => { + it("describes the remaining allowance before an amount is typed", () => { + expect(upliftAllowanceHelper(0, 5000).text).toBe("$5,000 left to auto-approve"); + expect(upliftAllowanceHelper(Number.NaN, 0).text).toBe( + "Limit used — new uplifts need admin approval", + ); + }); + + it("says the request approves instantly while it stays inside the allowance", () => { + expect(upliftAllowanceHelper(100, 500).text).toBe("Approves instantly · $400 left after this"); + }); + + it("says the request needs admin approval when it exceeds the allowance", () => { + expect(upliftAllowanceHelper(600, 500).text).toBe("Needs admin approval · $500 limit"); + }); + + it("marks instant approval without a tint and over-limit requests with a warning tint", () => { + expect(upliftAllowanceHelper(12, 500)).toMatchObject({ + icon: "check", + tint: false, + tone: "success", + }); + expect(upliftAllowanceHelper(600, 500)).toMatchObject({ + icon: "alert", + tint: true, + tone: "warning", + }); + expect(upliftAllowanceHelper(0, 440)).toMatchObject({ icon: null, tint: false, tone: "muted" }); + }); +}); + +describe("uplift card meta", () => { + const requestedAt = "2026-09-24T15:00:00.000Z"; + const nowMs = new Date(requestedAt).getTime() + 20 * 60_000; + + it("uses elapsed time while a request is pending", () => { + expect( + formatUpliftCardMeta( + { + status: "pending", + requestedAt, + requestedByName: "Adam Moussa", + decidedAt: "", + decidedByName: "", + }, + nowMs, + ), + ).toBe("Adam Moussa · 20m ago"); + }); + + it("records an auto-approved uplift as within allowance", () => { + expect( + formatUpliftCardMeta( + { + status: "auto_approved", + requestedAt, + requestedByName: "Adam Moussa", + decidedAt: "", + decidedByName: "", + }, + nowMs, + ), + ).toBe("Within allowance · Adam Moussa · Sep 24, 2026"); + }); +}); + +describe("uplift cell visual", () => { + it("collapses auto-approved into the approved cell word", () => { + const summary = { + hasUplift: true, + pendingCount: 0, + primaryStatus: "auto_approved" as const, + amount: 60, + }; + expect(getUpliftCellLabel(summary)).toBe("$60 Approved"); + expect(getUpliftCellVisual(summary)).toEqual({ + kind: "approved", + amount: "$60", + statusLabel: "Approved", + }); + }); + + it("shows a pending amount separately from the status word", () => { + expect( + getUpliftCellVisual({ + hasUplift: true, + pendingCount: 1, + primaryStatus: "pending", + amount: 123123, + }), + ).toEqual({ + kind: "pending", + amount: "$123,123", + statusLabel: "Pending", + }); + }); +}); diff --git a/src/test/lib/address/vendor-address.test.ts b/src/test/lib/address/vendor-address.test.ts index 4a2736dc..0191717e 100644 --- a/src/test/lib/address/vendor-address.test.ts +++ b/src/test/lib/address/vendor-address.test.ts @@ -32,8 +32,8 @@ describe("suggestAddresses", () => { }); it("is stable for the same input", () => { - // The list must not reshuffle between renders, or a row moves out from - // under the pointer mid-click. + // why: The list must not reshuffle between renders, or a row moves out from under the pointer + // mid-click. expect(suggestAddresses("4255 Anson Blvd")).toEqual(suggestAddresses("4255 Anson Blvd")); }); @@ -73,8 +73,8 @@ describe("address queries and links", () => { }); it("returns empty strings rather than a link to nowhere", () => { - // An href of "" renders as no link; a link to a bare maps search would look - // like an address we do not have. + // why: An href of "" renders as no link; a link to a bare maps search would look like an address we do + // not have. expect(mapsSearchUrl({})).toBe(""); expect(mapsEmbedUrl({ street: " " })).toBe(""); }); diff --git a/src/test/setup.ts b/src/test/setup.ts index d435a466..be8cf5e8 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -39,7 +39,7 @@ if (!localStorageIsUsable()) { }); } -// Files that opt into `@vitest-environment node` (real fetch/FormData semantics) have no window. +// why: Node-environment tests have no window, so matchMedia is stubbed only in jsdom. if (typeof window !== "undefined") { Object.defineProperty(window, "matchMedia", { writable: true,