diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index ca02832a..233cd701 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/dashboard/dashboard.visual.spec.ts b/e2e/dashboard/dashboard.visual.spec.ts index 2e60bf41..5bdc2e70 100644 --- a/e2e/dashboard/dashboard.visual.spec.ts +++ b/e2e/dashboard/dashboard.visual.spec.ts @@ -192,7 +192,6 @@ async function mockDashboardApi(page: Page, userRoles: string) { } if (pathname.includes("/Dashboard/Stats")) { - // The server reports no unassigned work for a dispatcher-scoped view. const unassigned = userRoles === "Dispatcher" ? 0 : 11; return fulfillJson(route, { data: { ...STATS, unassigned } }); } diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 494a354c..a413552a 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -46,6 +46,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)/locations/_components/site-delete-dialog.tsx b/src/app/(protected)/locations/_components/site-delete-dialog.tsx index 5b7ba58b..51d165b5 100644 --- a/src/app/(protected)/locations/_components/site-delete-dialog.tsx +++ b/src/app/(protected)/locations/_components/site-delete-dialog.tsx @@ -51,10 +51,7 @@ export function SiteDeleteDialog({ refetch, } = useSiteOpenWorkOrders(siteId, open); const openCount = openWorkOrders?.count ?? 0; - // Without a current count the warning cannot be trusted, so Delete waits for a successful check. - // `isFetching` covers reopening: the count cached from the last open is stale until the refetch lands. const countUnknown = isLoading || isFetching || isError; - // The server caps the id list; when it doesn't cover the stated count, open the site's whole board. const openIds = openWorkOrders?.workOrderIds ?? []; const openWorkOrdersHref = openIds.length > 0 && openIds.length >= openCount diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx index 1f94d7fa..4412d833 100644 --- a/src/app/(protected)/locations/_components/site-form-fields.tsx +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -59,8 +59,6 @@ export function SiteFormFields({ value={addressField.value} onInputChange={addressField.onChange} onSelect={(parts) => { - // One pick fills all three, each through its own controller so the - // form marks them dirty and revalidates (edit mode's Save depends on it). addressField.onChange(parts.street); cityField.onChange(parts.city); stateField.onChange(normalizeStateCode(parts.state) ?? ""); 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-approval-tabs.ts b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts index eefc3bf1..8d0e8473 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts +++ b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts @@ -30,8 +30,6 @@ const APPROVED_HEADER_CELLS = [ "Actions", ] as const; -// A rejection is terminal, so the archive ends with the decision and its reason -// instead of an Actions column. const REJECTED_HEADER_CELLS = [ "Work Order", "Service / Vendor", diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx index 9819d2cd..24c62083 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -295,8 +295,6 @@ function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) { ); } -// A failed queue is not an empty one: the page's error alert explains it, so the -// empty-state copy must not also claim the list has nothing in it. function UpliftApprovalsEmptyRow({ tab, colSpan, 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)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index 2aaf0005..001f8da7 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -48,9 +48,6 @@ const TAB_LABELS: Record = { rejected: "Rejected", }; -// A tab's total is only known once its queue has loaded at least once; until then the -// pill and pager must not claim a count (a loading or failed queue is not an empty one). -// A failed refetch keeps the last loaded page, so its total stays usable. function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null { return query.data ? query.data.totalCount : null; } 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/comp-doc/use-comp-doc-pagination.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts index b62308a6..eff1def5 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts @@ -58,7 +58,6 @@ export function useCompDocPagination( ); }, [blockCount]); - // Block content can change on any render (typing in the editor), so measure after each one. useLayoutEffect(() => { measure(); }); @@ -81,12 +80,10 @@ export function useCompDocPagination( onPageChangeRef.current?.({ current, total }); }, []); - // Any re-layout can move the sheet tops under the reader, even when the page count holds. useLayoutEffect(() => { report(); }); - // Width changes, late font/image loads and reflow do not re-render; observe them. const layoutKey = pages.map((page) => page.join(",")).join("|"); useEffect(() => { const observer = new ResizeObserver(() => { 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-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index a5d2e118..95a62680 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -192,15 +192,12 @@ export function SlideOverExtrasTab({ }: SlideOverExtrasTabProps) { const inputRef = useRef(null); const avetaAttached = hasExplicitAvetaFile(uploads); - // One selection at a time: a second pick during the async screen would read the - // same photo/video counts and could pass the per-work-order limit. const screeningRef = useRef(false); const [screening, setScreening] = useState(false); const busy = pending || screening; const addFiles = async (files: FileList | null) => { if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return; - // Snapshot synchronously: the input is reset right after this handler returns. const selected = Array.from(files); screeningRef.current = true; setScreening(true); 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..276b383c --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx @@ -0,0 +1,198 @@ +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 { isAdminUser } from "@/lib/auth/user-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"]; + onCreatePendingChange?: (pending: boolean) => void; +}; + +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, + onCreatePendingChange, +}: 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 && ( + { + onCreatePendingChange?.(true); + try { + await createUplift.mutateAsync(input); + } finally { + onCreatePendingChange?.(false); + } + }} + /> + )} + {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-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index d4e26b70..3f2d8db6 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -131,7 +131,6 @@ export function useSlideOverMediaState({ [detailForRow?.media, ownership, completionHasGallery], ); - // Per-work-order photo/video counts span both tabs, so count the whole session. const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); 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..227cd1cd 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); @@ -133,7 +133,8 @@ export function useWorkOrderSlideOver({ categorizeMediaMutation, }); const completionPending = uploadCompletionMutation.isPending; - const closeDisabled = Boolean(saving || mediaPending || completionPending); + const [upliftCreatePending, setUpliftCreatePending] = useState(false); + const closeDisabled = Boolean(saving || mediaPending || completionPending || upliftCreatePending); const editState = useSlideOverEditState({ row, @@ -146,7 +147,6 @@ export function useWorkOrderSlideOver({ initialTab, openRequestKey, }); - const uploadCompletionPdf = (file: File) => { uploadSlideOverCompletionPdf({ file, @@ -179,6 +179,8 @@ export function useWorkOrderSlideOver({ mediaPending, completionPending, closeDisabled, + upliftCreatePending, + setUpliftCreatePending, uploadMediaRequests, uploadExtraFiles, categorizeMedia, 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..9522168c 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,31 @@ import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel"; type SlideOverState = ReturnType; +function SlideOverUpliftsPanel({ + tab, + infoSource, + upliftCreatePending, + onUpliftCreatePendingChange, +}: { + tab: SlideOverTab; + infoSource: WorkOrderTableRow | null | undefined; + upliftCreatePending: boolean; + onUpliftCreatePendingChange?: (pending: boolean) => void; +}) { + const visible = tab === "uplifts"; + if (infoSource == null || (!visible && !upliftCreatePending)) return null; + return ( + + ); +} + type WorkOrderSlideOverTabsProps = { tab: SlideOverTab; state: SlideOverState; @@ -141,6 +167,13 @@ 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 9e357667..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,52 +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; - /** Closed work orders show a dash (not "Manage") that still opens the read-only history. */ - readOnly?: boolean; }; -export function UpliftCell({ summary, onOpen, readOnly = false }: 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 a9b51892..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 @@ -132,8 +132,8 @@ export function WoTableRowServiceCells({ e.stopPropagation()}> handlers.onOpenUplifts!(row) : undefined} - readOnly={isWorkOrderUpliftsReadOnly(row.status)} /> 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/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index 0bc1592c..3e08a6dd 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -53,16 +53,12 @@ export function MediaUploader({ mediaCounts, }: MediaUploaderProps) { const inputRef = useRef(null); - // One selection at a time: a second pick during the async screen would read the - // same photo/video counts and could pass the per-work-order limit. const screeningRef = useRef(false); const [screening, setScreening] = useState(false); const addFiles = async (files: FileList | null) => { if (readOnly || pending || screeningRef.current || !files || files.length === 0) return; - // Snapshot synchronously: the caller resets the input value right after this - // handler returns, which can detach a live FileList across awaits. const selected = Array.from(files); screeningRef.current = true; setScreening(true); 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..bb8d8d57 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker.tsx @@ -0,0 +1,108 @@ +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 = { + files: File[]; + onChange: (files: File[]) => void; +}; + +function isAllowedUpliftEvidenceFile(file: File): boolean { + if (ACCEPTED_TYPES.has(file.type)) return true; + return /\.(pdf|jpe?g|png)$/i.test(file.name); +} + +function allowedFiles(incoming: FileList | null): File[] { + if (!incoming || incoming.length === 0) return []; + return Array.from(incoming).filter(isAllowedUpliftEvidenceFile); +} + +function UpliftStagedFile({ file, onRemove }: { file: File; onRemove: () => void }) { + return ( +
+ + {file.name} + + +
+ ); +} + +export function UpliftAttachmentPicker({ files, onChange }: UpliftAttachmentPickerProps) { + const inputRef = useRef(null); + + const stageFiles = (incoming: FileList | null) => { + const next = allowedFiles(incoming); + if (next.length === 0) return; + onChange([...files, ...next]); + }; + + return ( +
+ Attachment + + Optional — one or more PDF, JPG, or PNG files. + + + { + stageFiles(event.target.files); + event.target.value = ""; + }} + /> + {files.length > 0 && ( +
+ {files.map((file, index) => ( + onChange(files.filter((_, itemIndex) => itemIndex !== index))} + /> + ))} +
+ )} +
+ ); +} 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..77ae243d 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,56 @@ 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; files: File[] }) => 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 +60,69 @@ export function WorkOrderUpliftCreateForm({ }: WorkOrderUpliftCreateFormProps) { const [amount, setAmount] = useState(""); const [notes, setNotes] = useState(""); - + const [files, setFiles] = useState([]); 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(), files }); setAmount(""); setNotes(""); + setFiles([]); } 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 32d4d1fd..bc81057a 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; @@ -18,67 +47,203 @@ type WorkOrderUpliftListItemProps = { onRevoke?: () => void; }; -export function WorkOrderUpliftListItem({ - uplift, - readOnly, - currentUserId, - currentUserIsAdmin, - 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, currentUserIsAdmin) && - Boolean(onRevoke); - return ( -
-
- + {formatUpliftStatusLabel(uplift.status)} + + ); +} + +function UpliftFiledAttachment({ + upliftId, + file, +}: { + upliftId: string | number; + file: WorkOrderUpliftAttachment; +}) { + const hasUrl = file.url !== ""; + const downloadEvidence = () => { + const documentId = /^\d+$/.test(file.id) ? file.id : undefined; + void upliftsApi.downloadEvidence(upliftId, file.name, documentId).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, + currentUserIsAdmin, + pendingAction, + onCancel, + onRevoke, +}: WorkOrderUpliftListItemProps) { + const nowMs = useUpliftClock(); + const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel); + const showRevoke = + canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) && 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 d8159230..00000000 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx +++ /dev/null @@ -1,104 +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; - currentUserIsAdmin: boolean; - 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, - currentUserIsAdmin, - 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 c7a60390..00000000 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx +++ /dev/null @@ -1,124 +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 { isAdminUser } from "@/lib/auth/user-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..50beee83 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -30,7 +30,7 @@ export default function WorkOrdersListPage() { const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters); useWorkOrderDeepLink({ onOpenSlideOver: page.handleOpenSlideOver, - onOpenUplifts: page.setUpliftsRow, + onOpenUplifts: page.handleOpenUplifts, }); return ( @@ -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/app/invite/_components/invite-wizard.tsx b/src/app/invite/_components/invite-wizard.tsx index 1dba3457..fc15478d 100644 --- a/src/app/invite/_components/invite-wizard.tsx +++ b/src/app/invite/_components/invite-wizard.tsx @@ -47,7 +47,6 @@ export function InviteWizard({ token, details, onInviteInvalid }: InviteWizardPr onSuccess: () => setStep(2), onError: (error) => { if (endedInvite(error)) return; - // A code sent moments ago is still valid, so the member can go on and enter it. if (inviteErrorCode(error) === "resend_too_soon") { setStep(2); return; diff --git a/src/app/invite/index.tsx b/src/app/invite/index.tsx index 519e4354..e041f036 100644 --- a/src/app/invite/index.tsx +++ b/src/app/invite/index.tsx @@ -33,9 +33,6 @@ function InviteBody({ token }: { token: string }) { ); } - // Only the server's own `invalid_invite` answer means the link is dead. A request that never - // got that answer (offline, timeout, 5xx) says nothing about the token, so offer a retry - // instead of sending the member to their admin for a new invite. if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") { return ( diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx index fd83575e..8413f418 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx @@ -26,7 +26,6 @@ function statusClassName(status: string): string { } function availableActions(request: VendorPortalUpliftRequest, locked: boolean, canAct: boolean) { - // Requests raised from the work order are visible but read-only to the vendor. const actionable = !locked && canAct && request.raisedByVendor !== false; return { canWithdraw: actionable && WITHDRAWABLE_STATUSES.has(request.status), @@ -34,7 +33,6 @@ function availableActions(request: VendorPortalUpliftRequest, locked: boolean, c }; } -// A vendor request stores the requested new NTE total; a work-order request stores the increase. function requestedTotal(request: VendorPortalUpliftRequest): number { const requested = Number(request.requestedNTE); return request.raisedByVendor === false ? Number(request.currentNTE ?? 0) + requested : requested; diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx index 0ee1744a..30edb260 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx @@ -50,7 +50,6 @@ export function UpliftRequestForm({ const submit = (event: React.FormEvent) => { event.preventDefault(); - // The picked file is not in state until its check settles; submitting now would validate the old one. if (checkingEvidence) return; const amount = Number.parseFloat(requested); const validationError = validateUpliftAmount(requested, currentNte, reason, { @@ -69,7 +68,6 @@ export function UpliftRequestForm({ const selection = ++latestSelection.current; setCheckingEvidence(Boolean(file)); const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined; - // A newer pick may have settled while this one's video metadata was still loading. if (selection !== latestSelection.current) return; setFormError(validationMessage ?? ""); setEvidenceFile(file); diff --git a/src/app/v/_components/vendor-portal-documents.tsx b/src/app/v/_components/vendor-portal-documents.tsx index 97fed559..ec4231c7 100644 --- a/src/app/v/_components/vendor-portal-documents.tsx +++ b/src/app/v/_components/vendor-portal-documents.tsx @@ -97,7 +97,6 @@ export function VendorPortalDocuments({ const [replacementId, setReplacementId] = useState(); const [progress, setProgress] = useState(); const [validationError, setValidationError] = useState(); - // Bumped on every new pick so a slower validation of an earlier file cannot upload it. const selectionRef = useRef(0); const uploadMutation = useMutation({ diff --git a/src/components/common/address-autocomplete-field.tsx b/src/components/common/address-autocomplete-field.tsx index 77b9343e..33ecd666 100644 --- a/src/components/common/address-autocomplete-field.tsx +++ b/src/components/common/address-autocomplete-field.tsx @@ -50,16 +50,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); }} @@ -81,8 +79,6 @@ export function AddressAutocompleteField({ helperText={helperText} fullWidth required={requiredMarker} - // Required is set on the input itself; MUI's `required` prop only - // adds the label asterisk, which Vendors must not render. 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-feed-actions.tsx b/src/components/notifications/notification-feed-actions.tsx index 64938205..30d27a0e 100644 --- a/src/components/notifications/notification-feed-actions.tsx +++ b/src/components/notifications/notification-feed-actions.tsx @@ -7,7 +7,6 @@ type NotificationFeedActionsProps = { export function NotificationFeedActions({ center }: NotificationFeedActionsProps) { const hasUnread = center.unreadIds.size > 0; - // Rows that need acknowledging are never cleared, so they alone do not enable "Clear all". const hasItems = center.sections.some((section) => section.items.some(isDismissable)); return ( diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx index a8f1e5f0..c8606783 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/config/menu.ts b/src/config/menu.ts index d397d0d2..7fbc9717 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -78,10 +78,6 @@ export const navGroups: NavGroup[] = [ // }, ]; -// Approvals navigation is gated on the same server-derived authority the -// uplift approve/reject API enforces (UpliftService.CanApprove tier roles, -// exposed via GET uplifts/can-approve), so the sidebar never shows the queue -// to a user the backend would refuse. const approvalsNavGroup: NavGroup = { title: "APPROVALS", items: [{ label: "Uplift Approvals", icon: BadgeDollarSign, path: "/uplifts" }], diff --git a/src/domain/auth/api/auth-api.ts b/src/domain/auth/api/auth-api.ts index c8f4f681..13451b4a 100644 --- a/src/domain/auth/api/auth-api.ts +++ b/src/domain/auth/api/auth-api.ts @@ -40,12 +40,10 @@ export async function changePassword(data: ChangePasswordPayload): Promise { return apiPost(API_PATHS.authentication.forgetPassword, { email }); } -// The server checks a code only against the email it was issued to. export async function verifyResetCode(email: string, code: string): Promise { return apiPost(API_PATHS.authentication.verificationCode, { email, code }); } diff --git a/src/domain/completion-templates/types/completion-template.ts b/src/domain/completion-templates/types/completion-template.ts index ec1a28b5..caf7a6a8 100644 --- a/src/domain/completion-templates/types/completion-template.ts +++ b/src/domain/completion-templates/types/completion-template.ts @@ -13,10 +13,8 @@ export interface CompletionTemplateLegacyFields { export interface CompletionTemplate { id: number; - /** The Document Title. */ name: string; extraSafetyNote: string | null; - /** In print order. */ procedures: CompletionTemplateProcedure[]; createdAt: string; /** `updatedAt ?? createdAt`: what the table shows as Last Updated. */ diff --git a/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts index 83010329..c5d1998e 100644 --- a/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts +++ b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts @@ -30,7 +30,6 @@ export function isTitleRequiredError(error: Error): boolean { function invalidateTemplates(queryClient: QueryClient) { void queryClient.invalidateQueries({ queryKey: queryKeys.completionTemplates.all }); - // The Services registry lists template names and clears links to a deleted template. void queryClient.invalidateQueries({ queryKey: queryKeys.services.all }); } diff --git a/src/domain/dashboard/utils/work-order-drilldown-links.ts b/src/domain/dashboard/utils/work-order-drilldown-links.ts index c1241382..21b216cb 100644 --- a/src/domain/dashboard/utils/work-order-drilldown-links.ts +++ b/src/domain/dashboard/utils/work-order-drilldown-links.ts @@ -101,12 +101,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/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts index dfb0a25f..3c169f93 100644 --- a/src/domain/locations/mappers/location-mapper.ts +++ b/src/domain/locations/mappers/location-mapper.ts @@ -236,7 +236,6 @@ export function mapLocationToBackend(location: unknown): Record state: item.state ?? "", zipCode: item.zipCode ?? "", phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "", - // A caller without a contacts list (e.g. address-only sync) keeps the stored legacy name. contact: mainContact ? mainContact.name : readString(item, "contact").trim(), contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "", accountId: item.accountId ?? "", diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 16829026..4e64003a 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -10,9 +10,7 @@ export interface LocationContact { export interface LocationListItem { id: string | number; name: string; - /** Client (account) display name. */ clientName: string; - /** Site Phone, independent of the contacts. */ sitePhone: string; address: string; city: string; diff --git a/src/domain/notifications/session/notification-session-provider.tsx b/src/domain/notifications/session/notification-session-provider.tsx index fc389b5d..0703456a 100644 --- a/src/domain/notifications/session/notification-session-provider.tsx +++ b/src/domain/notifications/session/notification-session-provider.tsx @@ -20,7 +20,6 @@ export function NotificationSessionProvider({ children }: { children: ReactNode const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState>( () => new Set(), ); - // A ref, not state: claiming must be synchronous so a re-run effect can never toast twice. const toastedSlaIds = useRef(new Set()); const dismiss = useCallback((ids: string[]) => { diff --git a/src/domain/notifications/types/notification-feed.ts b/src/domain/notifications/types/notification-feed.ts index af242b17..d221309d 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(), @@ -63,7 +63,6 @@ const slaAtRiskSchema = z.object({ export const notificationFeedSchema = z.object({ generatedAt: z.string(), sections: z.array(sectionSchema), - // Defaulted so a feed from a server without SLA alerts still parses. slaAtRisk: z.array(slaAtRiskSchema).default([]), }); 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/api/services-api.ts b/src/domain/services/api/services-api.ts index df781742..09701f90 100644 --- a/src/domain/services/api/services-api.ts +++ b/src/domain/services/api/services-api.ts @@ -20,8 +20,6 @@ const SERVICE_WORK_ORDER_TYPE_API = { Reactive: 6, } as const; -// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the -// client never confirmed, so it lists the PM catalog. const TYPE_TO_API: Record = { PM: WORK_ORDER_TYPE_API.PM, Reactive: WORK_ORDER_TYPE_API.Reactive, 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/team-member-invite/api/team-member-invite-api.ts b/src/domain/team-member-invite/api/team-member-invite-api.ts index b0545c4d..68610df5 100644 --- a/src/domain/team-member-invite/api/team-member-invite-api.ts +++ b/src/domain/team-member-invite/api/team-member-invite-api.ts @@ -1,7 +1,4 @@ -/** - * Anonymous invite registration. The invite token travels only in request bodies, - * never in a URL path or query, and the server scopes every call to that token. - */ +// invariant: the invite token travels only in request bodies, never in a URL path or query. import { isHTTPError } from "ky"; import { apiPost, apiPostNoContent } from "@/api/api"; @@ -34,7 +31,6 @@ export const teamMemberInviteApi = { const data = await apiPost(API_PATHS.teamMemberInvite.complete, { token: input.token, password: input.password, - // An empty value clears the phone the admin entered. phone: input.phone.trim(), }); const user = handleApiResponse(data); diff --git a/src/domain/team-members/mappers/team-member-mapper.ts b/src/domain/team-members/mappers/team-member-mapper.ts index de1652ba..a01a8393 100644 --- a/src/domain/team-members/mappers/team-member-mapper.ts +++ b/src/domain/team-members/mappers/team-member-mapper.ts @@ -88,7 +88,6 @@ export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[ export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail { const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record; const listItem = mapTeamMemberListItem(record); - // Present only on an update that re-sent a pending member's invite. const inviteEmailSent = record.inviteEmailSent ?? record.InviteEmailSent; return { ...listItem, diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts index fa0a5449..4899bd38 100644 --- a/src/domain/uplifts/api/uplifts-api.ts +++ b/src/domain/uplifts/api/uplifts-api.ts @@ -105,8 +105,9 @@ export const upliftsApi = { downloadEvidence: async ( id: string | number, fallbackFileName = "uplift-evidence", + documentId?: string | number, ): Promise => { - const response = await fetchEvidence(id, "upliftsApi.downloadEvidence"); + const response = await fetchEvidence(id, "upliftsApi.downloadEvidence", documentId); const blob = await response.blob(); saveBlob(blob, readContentDispositionFilename(response, fallbackFileName)); }, @@ -120,8 +121,9 @@ export const upliftsApi = { id: string | number, tab: Window | null, fallbackFileName = "uplift-evidence", + documentId?: string | number, ): Promise => { - const response = await fetchEvidence(id, "upliftsApi.openEvidence"); + const response = await fetchEvidence(id, "upliftsApi.openEvidence", documentId); const blob = await response.blob(); const type = blob.type.split(";")[0].trim().toLowerCase(); if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) { @@ -145,9 +147,14 @@ const INLINE_EVIDENCE_TYPES = new Set([ const EVIDENCE_URL_TTL_MS = 60_000; -async function fetchEvidence(id: string | number, operation: string): Promise { +async function fetchEvidence( + id: string | number, + operation: string, + documentId?: string | number, +): Promise { + const options = documentId == null ? undefined : { searchParams: { documentId } }; try { - return await apiRequestRaw("get", evidenceUrl(id), undefined, operation); + return await apiRequestRaw("get", evidenceUrl(id), options, operation); } catch (error) { if (error instanceof HTTPError) { throw evidenceHttpError(error.response.status); 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/uplifts/use-cases/use-uplifts-queue.ts b/src/domain/uplifts/use-cases/use-uplifts-queue.ts index 256250ad..a38386ea 100644 --- a/src/domain/uplifts/use-cases/use-uplifts-queue.ts +++ b/src/domain/uplifts/use-cases/use-uplifts-queue.ts @@ -10,8 +10,6 @@ export function useUpliftsQueue( queryKey: queryKeys.uplifts.queue(params as Record), queryFn: () => upliftsApi.list(params), placeholderData: keepPreviousData, - // The Uplift Approvals page reports a failed queue inline, so the global toast - // would announce the same failure twice. meta: { suppressErrorToast: true }, }); } diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts index ee7969ab..9e914350 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -19,8 +19,6 @@ export const VENDOR_UPLOAD_ACCEPT = type VendorUploadKind = "document" | "photo" | "video"; -// Mirrors the server allowlist: an allowlisted browser type wins, otherwise the -// extension decides (mobile browsers often send an empty or octet-stream type). const KIND_BY_MIME: Record = { "application/pdf": "document", "image/jpeg": "photo", @@ -114,7 +112,6 @@ export async function validateVendorDocument( if (file.size > MAX_VIDEO_BYTES) { return "Videos must be 100 MB or smaller."; } - // Pre-check; the server enforces it too. Unreadable metadata never blocks. const duration = await probeVideoDurationSeconds(file); if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`; 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..c1f4688e 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,45 @@ 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; + evidenceDocumentIds?: number[]; + } = { amount: input.amount, notes: input.notes, - }); + }; + if (input.evidenceDocumentId != null) { + body.evidenceDocumentId = input.evidenceDocumentId; + } + if (input.evidenceDocumentIds != null && input.evidenceDocumentIds.length > 0) { + body.evidenceDocumentIds = input.evidenceDocumentIds; + } + 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/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index 289187bf..362893df 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -227,7 +227,6 @@ export const workOrdersApi = { "Unable to verify the work order number: search result set exceeded the safety page limit.", ); } - // Every week plus undated rows: a WO# must be unique regardless of schedule. const result = await workOrdersApi.searchBoard({ search: normalized, datePreset: "Custom", 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..04d39b41 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,15 @@ export interface WorkOrderUplift { decidedAt: string; decidedByName: string; decisionNote: string; + attachments?: WorkOrderUpliftAttachment[]; } export interface CreateWorkOrderUpliftInput { amount: number; notes: string; - attachments?: File[]; + files?: File[]; + evidenceDocumentId?: number; + evidenceDocumentIds?: 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..02f44973 --- /dev/null +++ b/src/domain/work-orders/use-cases/submit-work-order-uplift.ts @@ -0,0 +1,77 @@ +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; + evidenceDocumentIds?: 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 evidenceDocumentIds: number[] = []; + for (const file of input.files ?? []) { + evidenceDocumentIds.push(await waitForPassedScan(workOrderId, file, deps)); + } + const [evidenceDocumentId] = evidenceDocumentIds; + return deps.create(workOrderId, { + amount: input.amount, + notes: input.notes, + evidenceDocumentId, + evidenceDocumentIds: evidenceDocumentIds.length > 0 ? evidenceDocumentIds : undefined, + }); +} 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/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 530bbd70..95572593 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -17,11 +17,6 @@ type AdvancedFilterPredicate = ( const isOpenUndated = (row: WorkOrderTableRow): boolean => !row.scheduledOn && !isTerminalCarryOverStatus(row.status); -// Mirrors the board search (ApplyDateRangeFilter and ApplyUnscheduledOnlyFilter) -// so the page and /board/search keep the same rows: a row is in a range -// when its date is, or when it is week-only and its target week overlaps. With -// no range, every week is searched and open undated rows are added. Closed -// undated rows are never unscheduled. const matchesDateRange: AdvancedFilterPredicate = (row, filters) => { if (filters.unscheduledOnly) { return isOpenUndated(row); diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index a027fd1e..245cbf68 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -125,7 +125,6 @@ function mapSearchFacetParams( /** Sent as one comma-separated `ids` value; the server then ignores every other filter. */ function mapWorkOrderIds(ids: Set | undefined): string | undefined { - // Normalized here too, so a caller that fills the set directly still stays within the server's limit. const normalized = ids?.size ? normalizeWorkOrderIds(ids) : []; return normalized.length > 0 ? normalized.join(",") : undefined; } @@ -151,13 +150,11 @@ 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 {}; } - // No range selected searches every week, undated rows included. Any - // selected range (This week included) strictly narrows the results. if (filters.dateRange === null) { return { datePreset: "Custom", 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 ebc73bee..9a5b3004 100644 --- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -11,7 +11,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[] = [ @@ -76,7 +76,6 @@ export function buildSiteOpenWorkOrdersHref(siteId: string | number): string { export function parseDashboardDrilldownParams( params: URLSearchParams, ): WorkOrderAdvancedFilters | null { - // An exact id set replaces every other filter: nothing else may hide one of those work orders. const workOrderIds = parseWorkOrderIdsParam(params.get("ids")); if (workOrderIds.length > 0) { return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) }; 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 2300335f..5372ed74 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 7b271420..7124625f 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,17 +85,63 @@ export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): return status === "Completed" || status === "Canceled"; } +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", + }; + } + 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( uplift: Pick, currentUserId: string | number | null | undefined, currentUserIsAdmin: boolean, ): 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; } if (currentUserIsAdmin) { - // Admins revoke only admin-approved uplifts; an auto-approval is the requester's to revoke. return false; } return ( @@ -123,3 +180,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/domain/work-orders/utils/work-order-media-utils.ts b/src/domain/work-orders/utils/work-order-media-utils.ts index 42c77c1b..5c8c28e9 100644 --- a/src/domain/work-orders/utils/work-order-media-utils.ts +++ b/src/domain/work-orders/utils/work-order-media-utils.ts @@ -17,7 +17,6 @@ export function classifyUpload(f: File): { if (t === "application/pdf" || /\.pdf$/i.test(f.name)) { return { fileType: "pdf", category: "Report" }; } - // Phones often send an empty or octet-stream type; fall back to the extension. const kind = resolveWorkOrderUploadKind(f); if (kind === "photo") return { fileType: "image", category: "Photo" }; if (kind === "video") return { fileType: "video", category: "Video" }; 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)/completion-docs/completion-docs-page.test.tsx b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx index 25580ff9..39261999 100644 --- a/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx +++ b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx @@ -32,7 +32,6 @@ vi.mock("@/providers/auth-context", async (importOriginal) => ({ useAuthContext: () => ({ user: { id: auth.id, userRoles: auth.userRoles } }), })); -// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down. let user = userEvent.setup({ delay: null }); function renderPage() { @@ -79,7 +78,6 @@ describe("Completion Docs page", { timeout: 15_000 }, () => { expect(within(backflow).getByText("Sep 25, 2026")).toBeInTheDocument(); expect(within(backflow).getByText("Jane Doe")).toBeInTheDocument(); const scrubber = within(table).getByRole("row", { name: /Scrubber Pit Augering/ }); - // Never updated: Last Updated falls back to the creation date; no creator shows a dash. expect(within(scrubber).getByText("Jun 1, 2026")).toBeInTheDocument(); expect(within(scrubber).getByText("—")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Completion Docs" })).toBeInTheDocument(); @@ -269,7 +267,6 @@ describe( }); it("never reuses one signed-in user's permissions for the next", async () => { - // Keeps the first answer cached after unmount, so only the per-user key can keep it out. const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } }, }); diff --git a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx index 8d45ebcf..d8448e7d 100644 --- a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx +++ b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx @@ -29,7 +29,6 @@ vi.mock("@/providers/auth-context", async (importOriginal) => ({ useAuthContext: () => ({ user: { id: "u1", userRoles: "Admin" } }), })); -// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down. let user = userEvent.setup({ delay: null }); function renderPage() { @@ -239,7 +238,6 @@ describe("Edit template", { timeout: 15_000 }, () => { [ "Escape", async () => { - // The disabled Save button drops focus; Escape is pressed from inside the form. screen.getByRole("textbox", { name: /Document Title/ }).focus(); await user.keyboard("{Escape}"); }, diff --git a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx index b74fd594..27672d72 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 }), @@ -148,7 +148,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); }); @@ -211,7 +211,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(); @@ -233,7 +233,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-approvals-one-amount.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx index 51c52285..7f6bae19 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx @@ -3,8 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; import { renderWithProviders } from "@/test/test-utils"; -// A work order that already carries an NTE: the uplift (the increase) and the -// requested NTE total differ, so any surface reading the wrong field shows up. const UPLIFT = "$4,999,997,000.00"; const REQUESTED_NTE_TOTAL = "$5,000,000,000.00"; diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx index 23323d0a..778e94e3 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx @@ -128,8 +128,6 @@ async function openRejectedTab() { } describe("Uplift Approvals rejected tab", () => { - // Load the page module once up front so the first test's budget is not spent on a - // cold import of the whole route. beforeAll(async () => { await import("@/app/(protected)/uplifts/index"); }, 60_000); 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-paginated-preview.test.tsx b/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx index 7cc2f88e..3bc7d21f 100644 --- a/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx +++ b/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx @@ -5,12 +5,7 @@ import { CompDocPreview } from "@/app/(protected)/workorders/_components/comp-do import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -/* - * jsdom has no layout, so the geometry the paginator reads is stubbed: - * sheets are 420px wide (A4 → 594px tall, 530px of content room), the - * letterhead block is 200px and every other block is 300px. Page 1 then holds - * one block and every later page holds one block as well. - */ +// why: jsdom has no layout, so the paginator geometry is stubbed below. const SHEET_WIDTH = 420; const LETTERHEAD_HEIGHT = 200; const BLOCK_HEIGHT = 300; @@ -60,7 +55,6 @@ function expectEverySheetFramed() { for (const sheet of sheets()) { expect(within(sheet).getAllByTestId("comp-doc-page-top-bar")).toHaveLength(1); expect(within(sheet).getAllByTestId("comp-doc-page-bottom-bar")).toHaveLength(1); - // A4 portrait: 420 × 297 / 210. expect(sheet.style.minHeight).toBe("594px"); } } @@ -168,7 +162,6 @@ describe("Completion template preview pagination", () => { scrollDocumentTo(SHEET_STRIDE + 5); expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 2 of 3"); - // The edit makes sheet 1 taller: sheet 2 now starts below the reader's position. sheetStride = 800; rerender( ({ 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/slide-over-uplifts-draft.test.tsx b/src/test/app/(protected)/workorders/slide-over-uplifts-draft.test.tsx new file mode 100644 index 00000000..a288257a --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-uplifts-draft.test.tsx @@ -0,0 +1,210 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over"; +import { WorkOrderSlideOverTabs } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs"; +import type { useWorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +type SlideOverState = ReturnType; + +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAuthContext: () => ({ user: { id: "u1" } }) }; +}); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderBoardDetail: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ + useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), + useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), + useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), + useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }), + useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", async () => { + const { useState } = await import("react"); + return { + useWorkOrderUplifts: () => ({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), + }), + useCreateWorkOrderUplift: () => { + const [isPending, setIsPending] = useState(false); + return { + isPending, + mutateAsync: () => { + setIsPending(true); + return new Promise(() => undefined); + }, + }; + }, + useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +function baseRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 42, + woNumber: "WO-42", + site: "SEA1", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Dispatcher", + scheduledOn: "2026-07-20", + completedDate: "", + targetWeek: "", + dayGroup: "07/20", + dayLabel: "Mon", + location: "Seattle", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + company: "Vendor Co", + tech: "Tech", + techPhone: "", + vendorId: "v1", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + additionalContacts: [], + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "PM — SEA1", + priority: "Sev 3", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + isAddOn: false, + avetaRequired: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + }; +} + +function upliftsState( + editing: boolean, + persisted: Partial, + draft: Partial, +): SlideOverState { + return { + error: null, + detailForRow: undefined, + isLoading: false, + editing, + infoSource: baseRow(persisted), + activeDraft: baseRow(draft), + upliftCreatePending: false, + setUpliftCreatePending: vi.fn(), + } as unknown as SlideOverState; +} + +function renderUplifts(state: SlideOverState) { + return renderWithProviders( + , + ); +} + +describe("WorkOrderSlideOverTabs Uplifts draft", () => { + it("keeps the saved work order type for the allowance while Info is unsaved", () => { + renderUplifts(upliftsState(true, { type: "PM" }, { type: "Emergency" })); + + expect(screen.getByText(/\$500 left to auto-approve/i)).toBeInTheDocument(); + expect(screen.queryByText(/\$5,000 left to auto-approve/i)).not.toBeInTheDocument(); + }); + + it("keeps the persisted allowance when the slide-over is not editing", () => { + renderUplifts(upliftsState(false, { type: "PM" }, { type: "Emergency" })); + + expect(screen.getByText(/\$500 left to auto-approve/i)).toBeInTheDocument(); + expect(screen.queryByText(/\$5,000 left to auto-approve/i)).not.toBeInTheDocument(); + }); + + it("keeps uplift requests open when only the unsaved Info status is completed", () => { + renderUplifts(upliftsState(true, { status: "Scheduled" }, { status: "Completed" })); + + expect(screen.queryByText(/uplifts can no longer be requested/i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /request uplift/i })).toBeInTheDocument(); + }); + + it("keeps the request form when a completed status exists only on an inactive draft", () => { + renderUplifts(upliftsState(false, { status: "Scheduled" }, { status: "Completed" })); + + expect(screen.queryByText(/uplifts can no longer be requested/i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /request uplift/i })).toBeInTheDocument(); + }); +}); + +describe("WorkOrderSlideOver in-flight uplift create", () => { + it("keeps the request pending and blocks close after leaving the Uplifts tab", async () => { + const row = baseRow(); + renderWithProviders( + , + ); + + fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: "120" } }); + fireEvent.change(screen.getByLabelText(/^notes/i), { target: { value: "Extra parts" } }); + fireEvent.click(screen.getByRole("button", { name: /request uplift/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); + }); + + fireEvent.click(screen.getByRole("tab", { name: "Info" })); + + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); + const amount = document.getElementById("wo-uplift-amount"); + expect(amount).toHaveValue(120); + expect(amount?.closest("[hidden]")).not.toBeNull(); + expect(screen.getByRole("button", { name: /request uplift/i, hidden: true })).toBeDisabled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/uplift-attachment-picker.test.tsx b/src/test/app/(protected)/workorders/uplift-attachment-picker.test.tsx new file mode 100644 index 00000000..5470eca3 --- /dev/null +++ b/src/test/app/(protected)/workorders/uplift-attachment-picker.test.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { UpliftAttachmentPicker } from "@/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker"; +import { renderWithProviders } from "@/test/test-utils"; + +function PickerHarness({ onChange }: { onChange: (files: File[]) => void }) { + const [files, setFiles] = useState([]); + return ( + { + setFiles(next); + onChange(next); + }} + /> + ); +} + +function selectFiles(files: File[]) { + const input = document.querySelector('input[type="file"]'); + fireEvent.change(input as HTMLInputElement, { target: { files } }); +} + +describe("UpliftAttachmentPicker", () => { + it("keeps every allowed file from one multi-select", () => { + const onChange = vi.fn(); + renderWithProviders(); + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + const notes = new File(["text"], "notes.txt", { type: "text/plain" }); + const photo = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" }); + + selectFiles([quote, notes, photo]); + + expect(screen.getByText("quote.pdf")).toBeInTheDocument(); + expect(screen.getByText("photo.jpg")).toBeInTheDocument(); + expect(screen.queryByText("notes.txt")).not.toBeInTheDocument(); + expect(onChange).toHaveBeenCalledWith([quote, photo]); + expect(document.querySelector('input[type="file"]')?.hasAttribute("multiple")).toBe(true); + }); + + it("appends a later selection instead of replacing the staged files", () => { + const onChange = vi.fn(); + renderWithProviders(); + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + const photo = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" }); + + selectFiles([quote]); + selectFiles([photo]); + + expect(screen.getByText("quote.pdf")).toBeInTheDocument(); + expect(screen.getByText("photo.jpg")).toBeInTheDocument(); + expect(onChange).toHaveBeenLastCalledWith([quote, photo]); + }); +}); diff --git a/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx b/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx index 1b11c20e..94f5e51d 100644 --- a/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx +++ b/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx @@ -86,7 +86,7 @@ describe("work order deep link", () => { }); }); - it("opens the uplifts dialog for an uplifts link instead of the slide-over", async () => { + it("opens the uplifts handler for an uplifts link", async () => { const open = handlers(); const { result } = renderLink("/workorders?wo=42&tab=uplifts", open); 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-row-service-cells-uplift-open.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx index 87f2bda4..37116300 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx @@ -46,8 +46,6 @@ function renderCells(row: WorkOrderTableRow, onOpenUplifts = vi.fn()) { } describe("WoTableRowServiceCells uplift column", () => { - // A closed work order whose uplifts were all cancelled or revoked has no live uplift, - // but its read-only uplift history must stay one click away. it.each(["Completed", "Canceled"] as const)( "opens the read-only uplift history from a dash on a %s work order with no live uplift", (status) => { @@ -59,14 +57,14 @@ describe("WoTableRowServiceCells uplift column", () => { const onOpenUplifts = renderCells(row); - const trigger = screen.getByRole("button", { name: "—" }); - expect(screen.queryByText("Manage")).not.toBeInTheDocument(); + const trigger = screen.getByRole("button", { name: /view uplifts/i }); + expect(screen.queryByRole("button", { name: /add uplift/i })).not.toBeInTheDocument(); fireEvent.click(trigger); expect(onOpenUplifts).toHaveBeenCalledWith(row); }, ); - it("offers Manage on an open work order with no live uplift", () => { + it("offers Add on an open work order with no live uplift", () => { const row = { id: 8, status: "Scheduled", @@ -75,7 +73,7 @@ describe("WoTableRowServiceCells uplift column", () => { const onOpenUplifts = renderCells(row); - fireEvent.click(screen.getByRole("button", { name: "Manage" })); + fireEvent.click(screen.getByRole("button", { name: /add uplift/i })); expect(onOpenUplifts).toHaveBeenCalledWith(row); }); }); 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-ids-drilldown.test.tsx b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx index 43a9a000..4f17793c 100644 --- a/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx @@ -75,13 +75,11 @@ describe("Work Orders exact id filter", () => { await waitFor(() => expect(searchParams()).not.toBe("weekly board")); const params = searchParams() as Record; expect(params.ids).toBe("4,9"); - // No saved or default facet travels with the id set. expect(params.statuses).toBeUndefined(); expect(params.dispatchers).toBeUndefined(); expect(params.types).toBeUndefined(); expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument(); - // The applied link params are consumed, like the dashboard drill-downs. expect(screen.getByRole("status", { name: "Current search" })).toHaveTextContent(/^$/); }); diff --git a/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx index 41c11c17..f6e70cda 100644 --- a/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { toast } from "react-toastify"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; +import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab"; import { REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { renderWithProviders } from "@/test/test-utils"; @@ -45,13 +45,14 @@ function respondToCreate(status: number, message: string) { async function submitInternalCreate(status: number, serverMessage: string) { const fetchMock = respondToCreate(status, serverMessage); - renderWithProviders(, { - withAuth: true, - }); + renderWithProviders( + , + { withAuth: true }, + ); - fireEvent.change(await screen.findByLabelText("Amount"), { target: { value: "125" } }); - fireEvent.change(screen.getByLabelText("Notes"), { target: { value: "Additional work" } }); - fireEvent.click(screen.getByRole("button", { name: "Create uplift" })); + fireEvent.change(await screen.findByLabelText(/amount/i), { target: { value: "125" } }); + fireEvent.change(screen.getByLabelText(/^notes/i), { target: { value: "Additional work" } }); + fireEvent.click(screen.getByRole("button", { name: /request uplift/i })); await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled()); expect( diff --git a/src/test/app/(protected)/workorders/work-order-uplift-evidence-download.test.tsx b/src/test/app/(protected)/workorders/work-order-uplift-evidence-download.test.tsx new file mode 100644 index 00000000..eceeed7c --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-uplift-evidence-download.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item"; +import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; +import { renderWithProviders } from "@/test/test-utils"; + +const downloadEvidence = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("@/domain/uplifts/api/uplifts-api", () => ({ + upliftsApi: { downloadEvidence }, +})); + +const uplift: WorkOrderUplift = { + id: 9, + status: "auto_approved", + amount: 25, + notes: "Leak", + requestedAt: "2026-07-20T10:00:00Z", + requestedById: "dispatcher-1", + requestedByName: "Dispatcher", + decidedAt: "", + decidedByName: "", + decisionNote: "", + attachments: [ + { id: "22", name: "quote.pdf", url: "" }, + { id: "23", name: "photo.jpg", url: "" }, + ], +}; + +describe("WorkOrderUpliftListItem evidence download", () => { + it("downloads each attachment by its document id", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: "photo.jpg" })); + + expect(downloadEvidence).toHaveBeenCalledWith(9, "photo.jpg", "23"); + }); +}); 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 624c6ac3..ed89ef80 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(), authUser: { id: "u1", userRoles: "Dispatcher" }, })); @@ -87,12 +90,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( @@ -104,6 +107,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, @@ -114,53 +141,115 @@ 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)); }); it("shows no Revoke to an admin on the auto-approved uplift they requested", () => { mockState.authUser = { id: "admin-1", userRoles: "Admin" }; mockState.uplifts = [{ ...pendingUplift, status: "auto_approved", requestedById: "admin-1" }]; - renderWithProviders(, { - withAuth: true, - }); + renderWithProviders( + , + { withAuth: true }, + ); - expect(screen.getByText("Auto")).toBeInTheDocument(); + expect(screen.getByText("Auto-approved")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument(); }); @@ -170,9 +259,14 @@ describe("WorkOrderUpliftsDialog affordances", () => { { ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" }, ]; - renderWithProviders(, { - withAuth: true, - }); + renderWithProviders( + , + { withAuth: true }, + ); expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument(); }); @@ -208,4 +302,87 @@ 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/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx index a0085787..5bfd9c05 100644 --- a/src/test/app/v/vendor-portal-documents.test.tsx +++ b/src/test/app/v/vendor-portal-documents.test.tsx @@ -154,7 +154,6 @@ describe("VendorPortalDocuments", () => { expect(upload).not.toHaveBeenCalled(); }); - // Three videos already on the work order (dispatcher and vendor uploads together). const fullCounts = { maxPhotos: 10, maxVideos: 3, 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/uplifts/api/uplifts-api.test.ts b/src/test/domain/uplifts/api/uplifts-api.test.ts index 656d155e..908e8419 100644 --- a/src/test/domain/uplifts/api/uplifts-api.test.ts +++ b/src/test/domain/uplifts/api/uplifts-api.test.ts @@ -43,6 +43,27 @@ describe("upliftsApi", () => { await expect(upliftsApi.canApprove(2)).resolves.toBe(false); }); + it("requests one linked document when a document id is provided", async () => { + apiRequestRaw.mockResolvedValueOnce( + new Response("%PDF", { + headers: { "Content-Disposition": 'attachment; filename="photo.jpg"' }, + }), + ); + URL.createObjectURL = vi.fn(() => "blob:evidence"); + URL.revokeObjectURL = vi.fn(); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + await upliftsApi.downloadEvidence(7, "photo.jpg", 23); + + expect(apiRequestRaw).toHaveBeenCalledWith( + "get", + "uplifts/7/evidence", + { searchParams: { documentId: 23 } }, + "upliftsApi.downloadEvidence", + ); + click.mockRestore(); + }); + describe("openEvidence", () => { function evidenceResponse(body: string, contentType: string): Response { return new Response(body, { diff --git a/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx index f71eb8d4..2296c47a 100644 --- a/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx +++ b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx @@ -20,8 +20,6 @@ afterEach(() => { describe("useUpliftsQueue", () => { it("does not show the global error toast, since the approvals page reports the failure inline", async () => { vi.spyOn(upliftsApi, "list").mockRejectedValue(new Error("Failed to load uplift queue")); - // The real app query client runs the QueryCache's global onError handler, which - // toasts unless the query opts out. const queryClient = createAppQueryClient(); queryClient.setDefaultOptions({ queries: { retry: false } }); 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..71c770c0 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,62 @@ 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("includes every scanned document id when several files passed", async () => { + apiPost.mockResolvedValueOnce({ id: 9, status: "pending", amount: 25, notes: "Leak" }); + + await workOrderUpliftsApi.create(7, { + amount: 25, + notes: "Leak", + evidenceDocumentId: 22, + evidenceDocumentIds: [22, 23], + }); + + expect(apiPost).toHaveBeenCalledWith("workorders/7/uplifts", { + amount: 25, + notes: "Leak", + evidenceDocumentId: 22, + evidenceDocumentIds: [22, 23], + }); + }); + + 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 eea7afe8..88a45464 100644 --- a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts +++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts @@ -40,8 +40,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..5afdc03f --- /dev/null +++ b/src/test/domain/work-orders/use-cases/submit-work-order-uplift.test.ts @@ -0,0 +1,101 @@ +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", files: [] }, collaborators); + + expect(collaborators.uploadEvidence).not.toHaveBeenCalled(); + expect(collaborators.create).toHaveBeenCalledWith(7, { + amount: 25, + notes: "Leak", + evidenceDocumentId: undefined, + evidenceDocumentIds: 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", files: [quote] }, collaborators); + + expect(collaborators.uploadEvidence).toHaveBeenCalledWith(7, quote); + expect(collaborators.create).toHaveBeenCalledWith(7, { + amount: 25, + notes: "Leak", + evidenceDocumentId: 22, + evidenceDocumentIds: [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", files: [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", files: [quote] }, collaborators), + ).rejects.toThrow(/still being scanned/i); + expect(collaborators.create).not.toHaveBeenCalled(); + expect(collaborators.sleep).toHaveBeenCalledTimes(30); + }); + + it("uploads every file and sends each passed document id", async () => { + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + const photo = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" }); + const collaborators = deps({ + uploadEvidence: vi + .fn() + .mockResolvedValueOnce({ id: 22, scanStatus: "Passed" }) + .mockResolvedValueOnce({ id: 23, scanStatus: "Passed" }), + }); + + await submitWorkOrderUplift( + 7, + { amount: 25, notes: "Leak", files: [quote, photo] }, + collaborators, + ); + + expect(collaborators.uploadEvidence).toHaveBeenNthCalledWith(1, 7, quote); + expect(collaborators.uploadEvidence).toHaveBeenNthCalledWith(2, 7, photo); + expect(collaborators.create).toHaveBeenCalledWith(7, { + amount: 25, + notes: "Leak", + evidenceDocumentId: 22, + evidenceDocumentIds: [22, 23], + }); + }); +}); 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/completion-doc-pagination.test.ts b/src/test/domain/work-orders/utils/completion-doc-pagination.test.ts index 465d5daa..d33caa6d 100644 --- a/src/test/domain/work-orders/utils/completion-doc-pagination.test.ts +++ b/src/test/domain/work-orders/utils/completion-doc-pagination.test.ts @@ -27,7 +27,6 @@ describe("paginateBlocks", () => { }); it("charges the letterhead to the first page only", () => { - // Page 1 has 800 - 500 = 300 left; later pages have the full 800. expect( paginateBlocks({ headerHeight: 500, blockHeights: [300, 400, 400], pageHeight: 800 }), ).toEqual([[0], [1, 2]]); 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 9798afb8..815a7110 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 @@ -488,8 +488,6 @@ describe("matchesAdvancedFilters", () => { customTo: "2026-09-25", }; - // Week-only: a target week overlapping the range matches, whatever date the - // row still carries. const weekOnlyStaleDate = makeRow({ scheduledOn: "2026-10-02", targetWeek: "2026-09-21", @@ -513,8 +511,6 @@ describe("matchesAdvancedFilters", () => { expect(matchesAdvancedFilters(weekOnlyOutside, filters)).toBe(false); expect(matchesAdvancedFilters(datedOutsideWithWeek, filters)).toBe(false); expect(matchesAdvancedFilters(undated, filters)).toBe(false); - // No range: every week, plus open undated rows. Closed undated rows are - // never unscheduled, with or without the Unscheduled filter. const noRange = { ...filters, dateRange: null }; const unscheduledOnly = { ...filters, unscheduledOnly: true }; const completedUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Completed" }); @@ -603,7 +599,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 a9c0bc65..aaef5cc8 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"; @@ -95,3 +99,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/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts index e1892159..3f195734 100644 --- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts @@ -55,7 +55,6 @@ describe("work-order-media-limits", () => { expect(workOrderFileSizeRejection(file("IMG_2.HEIC", "", 11_000_000))).toBe( "Photos must be 10 MB or smaller.", ); - // A foreign declared type never widens the class: this .jpg is still sized as a photo. expect(workOrderFileSizeRejection(file("IMG_3.jpg", "video/3gpp", 60_000_000))).toBe( "Photos must be 10 MB or smaller.", ); 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,