Merge branch 'dev' into feature/wo-advanced-filters-parity

This commit is contained in:
Arthur Bassi 2026-08-19 09:33:07 -03:00 • committed by GitHub
commit d534e92d03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1584 additions and 156 deletions

View file

@ -38,6 +38,11 @@ export const API_PATHS = {
mediaItem: (id: string | number, mediaId: string | number) =>
`workorders/${id}/media/${mediaId}`,
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
`workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>
`workorders/${workOrderId}/uplifts/${upliftId}/revoke`,
lookupsDispatchers: "workorders/lookups/dispatchers",
},
pmSchedule: {

View file

@ -0,0 +1,48 @@
import type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift";
import {
getUpliftCellLabel,
getUpliftStatusPillStyle,
} from "@/domain/work-orders/utils/uplift-display-utils";
type UpliftCellProps = {
summary: UpliftSummary;
onOpen?: () => void;
};
export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
const label = getUpliftCellLabel(summary);
if (!onOpen) {
return label ? (
<span className="text-[11px] text-[var(--color-text-muted)]">{label}</span>
) : (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
);
}
const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus;
const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
title={label ? "View uplifts" : "Manage uplifts"}
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
>
{label && pillStyle ? (
<span
className="inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-semibold whitespace-nowrap"
style={pillStyle}
>
{label}
</span>
) : (
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
)}
</button>
);
}

View file

@ -16,6 +16,7 @@ type UseWoTableRowHandlersOptions = {
onWeekChange: (mondayIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onScheduleHighlight: (id: string | number | null) => void;
@ -28,6 +29,7 @@ export function useWoTableRowHandlers({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
onPatch,
onScheduleHighlight,
@ -62,6 +64,12 @@ export function useWoTableRowHandlers({
onOpenCompDoc(row);
}
: undefined,
onOpenUplifts: onOpenUplifts
? (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenUplifts(row);
}
: undefined,
onOpenMedia: (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenMedia(row);
@ -136,6 +144,7 @@ export function useWoTableRowHandlers({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
onPatch,
onScheduleHighlight,

View file

@ -12,9 +12,10 @@ export const WO_TABLE_COLS = [
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 },
{ key: "uplift", label: "UPLIFT", w: 108 },
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 },
] as const;
export const WO_TABLE_MIN_WIDTH = 1634;
export const WO_TABLE_MIN_WIDTH = 1742;
export const WO_TABLE_ROW_HEIGHT = 52;

View file

@ -229,6 +229,7 @@ export function WoTableInlineRowCells({
<InlineMutedDashCell />
<InlineMutedDashCell />
<InlineMutedDashCell />
</>
);
}

View file

@ -6,7 +6,10 @@ import { CompDocCell } from "./cells/comp-doc-cell";
import { DateCell } from "./cells/date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { UpliftCell } from "./cells/uplift-cell";
import { VendorCell } from "./cells/vendor-cell";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
import type { WoTableRowHandlers } from "./wo-table-row";
type WoTableRowServiceCellsProps = {
@ -103,6 +106,17 @@ export function WoTableRowServiceCells({
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
</td>
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<UpliftCell
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
onOpen={
canOpenUpliftsDialog(row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY, row.status) &&
handlers.onOpenUplifts
? () => handlers.onOpenUplifts!(row)
: undefined
}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}

View file

@ -21,6 +21,7 @@ export type WoTableRowHandlers = {
onView: (row: WorkOrderTableRow) => void;
onEdit: (row: WorkOrderTableRow) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
onScheduleDate: (id: string | number, date: string) => void;

View file

@ -33,6 +33,7 @@ type WoTableProps = {
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onClearSearch?: () => void;
onOpenAdvancedFilters?: () => void;
@ -49,6 +50,7 @@ export function WoTable({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
onClearSearch,
onOpenAdvancedFilters,
@ -71,6 +73,7 @@ export function WoTable({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
onPatch,
onScheduleHighlight: setDueDateHighlightId,

View file

@ -1,88 +1,22 @@
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
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 = {
wizardOpen: boolean;
onWizardOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
sites: LocationOption[];
pmTypes: string[];
vendorTechs: string[];
advOpen: boolean;
onAdvOpenChange: (open: boolean) => void;
advInitial: WorkOrderAdvancedFilters;
onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
onClearAdvanced: () => void;
activeDocRow: WorkOrderTableRow | null;
onDocOpenChange: (open: boolean) => void;
completionDocs: Record<string, CompDocData>;
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange: (date: string) => void | Promise<void>;
activeMediaRow: WorkOrderTableRow | null;
onMediaOpenChange: (open: boolean) => void;
activeUpliftsRow: WorkOrderTableRow | null;
onCloseUplifts: () => void;
};
export function WorkOrdersListOverlays({
wizardOpen,
onWizardOpenChange,
users,
sites,
pmTypes,
vendorTechs,
advOpen,
onAdvOpenChange,
advInitial,
onApplyAdvanced,
onClearAdvanced,
activeDocRow,
onDocOpenChange,
completionDocs,
onSaveCompDoc,
onDateCompletedChange,
activeMediaRow,
onMediaOpenChange,
activeUpliftsRow,
onCloseUplifts,
}: WorkOrdersListOverlaysProps) {
return (
<>
<WorkOrdersAdvancedSearchSheet
open={advOpen}
onOpenChange={onAdvOpenChange}
initial={advInitial}
onApply={onApplyAdvanced}
onClearAll={onClearAdvanced}
sites={sites}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
<NewWoWizard open={wizardOpen} onOpenChange={onWizardOpenChange} users={users} />
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={!!activeDocRow}
onOpenChange={onDocOpenChange}
data={completionDocs[String(activeDocRow.id)]}
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
// Notes/generatedAt stay client-local (no content endpoint on this branch).
onSave={onSaveCompDoc}
onDateCompletedChange={onDateCompletedChange}
/>
)}
{activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
onOpenChange={onMediaOpenChange}
{activeUpliftsRow != null && (
<WorkOrderUpliftsDialog
row={activeUpliftsRow}
open={Boolean(activeUpliftsRow)}
onClose={onCloseUplifts}
/>
)}
</>

View file

@ -0,0 +1,95 @@
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
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 { 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";
type WorkOrdersListPagePanelsProps = {
page: ReturnType<typeof useWorkOrdersListPage>;
};
export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps) {
const { filters, tableData, activeDocRow, users, locations, vendors, pmTypes, vendorTechs } =
page;
return (
<>
<WorkOrdersListWizard
open={page.wizardOpen}
onOpenChange={page.setWizardOpen}
users={users}
rows={tableData.rows}
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
/>
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={locations}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
onClose={page.handleCloseSlideOver}
/>
<WorkOrderCancelDialog
row={page.confirmCancel}
isPending={page.isPatchPending}
onDialogClose={page.cancelDialogClose}
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{page.activeMediaRow != null && (
<MediaUploadModal
row={page.activeMediaRow}
open={Boolean(page.activeMediaRow)}
onOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={Boolean(activeDocRow)}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
<WorkOrdersListOverlays
activeUpliftsRow={page.activeUpliftsRow}
onCloseUplifts={() => page.setUpliftsRow(null)}
/>
</>
);
}

View file

@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
} from "@mui/material";
import { Text } from "@/components/ui/text";
type RevokeUpliftDialogProps = {
open: boolean;
onClose: () => void;
onConfirm: (reason: string) => void;
pending?: boolean;
requireReason?: boolean;
title?: string;
description?: string;
};
export function RevokeUpliftDialog({
open,
onClose,
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.",
}: RevokeUpliftDialogProps) {
const [reason, setReason] = useState("");
// Clear only after the dialog closes (parent closes on mutation success).
useEffect(() => {
if (!open) {
setReason("");
}
}, [open]);
const handleClose = () => {
onClose();
};
const handleConfirm = () => {
onConfirm(reason.trim());
};
const reasonMissing = requireReason && reason.trim() === "";
return (
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Text as="p" variant="description" className="mb-3">
{description}
</Text>
<TextField
label={requireReason ? "Reason (required)" : "Reason (optional)"}
fullWidth
multiline
minRows={2}
required={requireReason}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button
color="error"
variant="contained"
disabled={pending || reasonMissing}
onClick={handleConfirm}
>
Revoke
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,85 @@
import { useState } from "react";
import { Button, TextField } from "@mui/material";
import { Text } from "@/components/ui/text";
import { formatUpliftMoney } from "@/domain/work-orders/utils/uplift-display-utils";
type WorkOrderUpliftCreateFormProps = {
pending?: boolean;
remainingAllowance: number;
onSubmit: (input: { amount: number; notes: string }) => void | Promise<void>;
};
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.`;
}
export function WorkOrderUpliftCreateForm({
pending = false,
remainingAllowance,
onSubmit,
}: WorkOrderUpliftCreateFormProps) {
const [amount, setAmount] = useState("");
const [notes, setNotes] = useState("");
const parsedAmount = Number(amount);
const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0;
const handleSubmit = async () => {
try {
await onSubmit({ amount: parsedAmount, notes: notes.trim() });
setAmount("");
setNotes("");
} catch {
// Mutation onError already toasts; keep form values after failure.
}
};
return (
<div className="rounded-lg border border-[var(--color-border)] p-3">
<Text as="p" variant="label" className="mb-2">
Request uplift
</Text>
<div className="grid gap-2">
<TextField
label="Amount"
type="number"
size="small"
value={amount}
onChange={(event) => setAmount(event.target.value)}
helperText={amountHelperText(parsedAmount, remainingAllowance)}
slotProps={{ htmlInput: { min: 0, step: "0.01" } }}
/>
<TextField
label="Notes"
size="small"
multiline
minRows={2}
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
<TextField
label="Attachments"
size="small"
disabled
helperText="Attachment upload will be wired when BE accepts files."
/>
<Button
variant="contained"
disabled={!canSubmit || pending}
onClick={() => {
void handleSubmit();
}}
>
Create uplift
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,80 @@
import { Button } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
import {
canRevokeWorkOrderUplift,
formatUpliftMoney,
formatUpliftStatusLabel,
getUpliftStatusPillStyle,
} from "@/domain/work-orders/utils/uplift-display-utils";
type WorkOrderUpliftListItemProps = {
uplift: WorkOrderUplift;
readOnly: boolean;
currentUserId: string | number | null | undefined;
pendingAction?: boolean;
onCancel?: () => void;
onRevoke?: () => void;
};
export function WorkOrderUpliftListItem({
uplift,
readOnly,
currentUserId,
pendingAction = false,
onCancel,
onRevoke,
}: WorkOrderUpliftListItemProps) {
const pillStyle = getUpliftStatusPillStyle(uplift.status);
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
const showRevoke =
!readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke);
return (
<div className="rounded-lg border border-[var(--color-border)] p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span
className="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={pillStyle}
>
{formatUpliftStatusLabel(uplift.status)}
</span>
<Text as="span" variant="label">
{formatUpliftMoney(uplift.amount)}
</Text>
</div>
{Boolean(uplift.notes) && (
<Text as="p" variant="description" className="mb-2">
{uplift.notes}
</Text>
)}
{Boolean(uplift.requestedByName) && (
<Text as="p" variant="caption" tone="muted">
Requested by {uplift.requestedByName}
</Text>
)}
{showCancel && (
<Button
size="small"
color="inherit"
disabled={pendingAction}
onClick={onCancel}
className="mt-2"
>
Cancel pending
</Button>
)}
{showRevoke && (
<Button
size="small"
color="error"
disabled={pendingAction}
onClick={onRevoke}
className="mt-2"
>
Revoke
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,101 @@
import { CircularProgress } from "@mui/material";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { Text } from "@/components/ui/text";
import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form";
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-display-utils";
type WorkOrderUpliftsDialogContentProps = {
readOnly: boolean;
currentUserId: string | number | null | undefined;
readOnlyStatusLabel?: string;
isLoading: boolean;
error: Error | null;
uplifts: WorkOrderUplift[];
remainingAllowance: number;
actionPending: boolean;
createPending: boolean;
onRetry: () => void;
onCreate: (input: CreateWorkOrderUpliftInput) => void | Promise<void>;
onCancelUplift: (upliftId: string | number) => void;
onRevokeUplift: (upliftId: string | number) => void;
};
export function WorkOrderUpliftsDialogContent({
readOnly,
currentUserId,
readOnlyStatusLabel,
isLoading,
error,
uplifts,
remainingAllowance,
actionPending,
createPending,
onRetry,
onCreate,
onCancelUplift,
onRevokeUplift,
}: WorkOrderUpliftsDialogContentProps) {
const hasOpenRequest = hasOpenWorkOrderUplift(uplifts);
return (
<>
{readOnly && (
<Text as="p" variant="description" tone="muted">
This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only.
</Text>
)}
{Boolean(error) && (
<QueryErrorPanel
compact
message={error instanceof Error ? error.message : "Failed to load uplifts."}
onRetry={onRetry}
/>
)}
{isLoading && (
<div className="flex justify-center py-6">
<CircularProgress size={24} />
</div>
)}
{!isLoading && !error && uplifts.length === 0 && (
<Text as="p" variant="description" tone="muted">
No uplift requests yet.
</Text>
)}
{!isLoading &&
uplifts.map((uplift) => (
<WorkOrderUpliftListItem
key={String(uplift.id)}
uplift={uplift}
readOnly={readOnly}
currentUserId={currentUserId}
pendingAction={actionPending}
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}
/>
))}
{!readOnly && !isLoading && hasOpenRequest && (
<Text as="p" variant="description" tone="muted">
Cancel the pending request to file a new one.
</Text>
)}
{!readOnly && !isLoading && !hasOpenRequest && (
<WorkOrderUpliftCreateForm
pending={createPending}
remainingAllowance={remainingAllowance}
onSubmit={onCreate}
/>
)}
</>
);
}

View file

@ -0,0 +1,122 @@
import { useMemo, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog";
import { WorkOrderUpliftsDialogContent } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift";
import {
useCancelWorkOrderUplift,
useCreateWorkOrderUplift,
useRevokeWorkOrderUplift,
useWorkOrderUplifts,
} from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import {
getRemainingUpliftAllowance,
isWorkOrderUpliftsReadOnly,
upliftRevokeRequiresReason,
} from "@/domain/work-orders/utils/uplift-display-utils";
import { useAuthContext } from "@/providers/auth-context";
type RevokeTarget = {
id: string | number;
status: WorkOrderUpliftStatus;
};
type WorkOrderUpliftsDialogProps = {
row: WorkOrderTableRow | null;
open: boolean;
onClose: () => void;
};
export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) {
const { user } = useAuthContext();
const workOrderId = row?.id ?? null;
const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true;
const {
data: uplifts = [],
isLoading,
error,
refetch,
} = useWorkOrderUplifts(open ? workOrderId : null);
const createUplift = useCreateWorkOrderUplift(workOrderId ?? "");
const cancelUplift = useCancelWorkOrderUplift(workOrderId ?? "");
const revokeUplift = useRevokeWorkOrderUplift(workOrderId ?? "");
const [revokeTarget, setRevokeTarget] = useState<RevokeTarget | null>(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 (
<>
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Uplifts — WO {row?.woNumber ?? ""}</DialogTitle>
<DialogContent className="flex flex-col gap-3">
<WorkOrderUpliftsDialogContent
readOnly={readOnly}
currentUserId={user?.id}
readOnlyStatusLabel={row?.status}
isLoading={isLoading}
error={error}
uplifts={uplifts}
remainingAllowance={remainingAllowance}
actionPending={actionPending}
createPending={createUplift.isPending}
onRetry={() => void refetch()}
onCreate={async (input) => {
await createUplift.mutateAsync(input);
}}
onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)}
onRevokeUplift={handleRevokeUplift}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
<RevokeUpliftDialog
open={revokeTarget != null}
pending={revokeUplift.isPending}
requireReason={
revokeTarget != null ? upliftRevokeRequiresReason(revokeTarget.status) : false
}
title={revokeDialogCopy.title}
description={revokeDialogCopy.description}
onClose={() => setRevokeTarget(null)}
onConfirm={(reason) => {
if (revokeTarget == null) {
return;
}
revokeUplift.mutate(
{ upliftId: revokeTarget.id, reason },
{ onSuccess: () => setRevokeTarget(null) },
);
}}
/>
</>
);
}

View file

@ -19,6 +19,7 @@ export function useWorkOrdersListPage() {
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
@ -62,6 +63,11 @@ 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 handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
@ -107,6 +113,7 @@ export function useWorkOrdersListPage() {
setDocRow,
mediaRow,
setMediaRow,
setUpliftsRow,
completionDocs,
filters,
tableData,
@ -122,6 +129,7 @@ export function useWorkOrdersListPage() {
activeSlideOverRow,
activeDocRow,
activeMediaRow,
activeUpliftsRow,
handleOpenSlideOver,
handleCloseSlideOver,
handleConfirmCancel,

View file

@ -1,15 +1,10 @@
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useAuthContext } from "@/providers/auth-context";
@ -17,7 +12,7 @@ import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const { user } = useAuthContext();
const page = useWorkOrdersListPage();
const { filters, tableData, activeDocRow } = page;
const { filters, tableData } = page;
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
@ -87,79 +82,13 @@ export default function WorkOrdersListPage() {
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={page.setDocRow}
onOpenMedia={page.setMediaRow}
onOpenUplifts={page.setUpliftsRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={page.patchField}
/>
<WorkOrdersListWizard
open={page.wizardOpen}
onOpenChange={page.setWizardOpen}
users={page.users}
rows={tableData.rows}
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
/>
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={page.locations}
users={page.users}
pmTypes={page.pmTypes}
vendorTechs={page.vendorTechs}
/>
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
users={page.users}
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
onClose={page.handleCloseSlideOver}
/>
<WorkOrderCancelDialog
row={page.confirmCancel}
isPending={page.isPatchPending}
onDialogClose={page.cancelDialogClose}
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{page.activeMediaRow != null && (
<MediaUploadModal
row={page.activeMediaRow}
open={Boolean(page.activeMediaRow)}
onOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={Boolean(activeDocRow)}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
<WorkOrdersListPagePanels page={page} />
</Box>
);
}

View file

@ -0,0 +1,61 @@
import { API_PATHS } from "@/api/api-paths";
import { apiGet, apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import {
mapWorkOrderUplift,
mapWorkOrderUplifts,
} from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
function extractUpliftItems(data: unknown): unknown[] {
if (Array.isArray(data)) {
return data;
}
const record = typeof data === "object" && data != null ? (data as Record<string, unknown>) : {};
const items = record.items ?? record.Items ?? record.uplifts ?? record.Uplifts;
return Array.isArray(items) ? items : [];
}
export const workOrderUpliftsApi = {
list: async (workOrderId: string | number): Promise<WorkOrderUplift[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.uplifts(workOrderId));
const unwrapped = handleApiResponse<unknown>(data);
return mapWorkOrderUplifts(extractUpliftItems(unwrapped));
},
create: async (
workOrderId: string | number,
input: CreateWorkOrderUpliftInput,
): Promise<WorkOrderUplift> => {
const data = await apiPost<unknown>(API_PATHS.workOrder.uplifts(workOrderId), {
amount: input.amount,
notes: input.notes,
});
return mapWorkOrderUplift(handleApiResponse(data));
},
cancel: async (
workOrderId: string | number,
upliftId: string | number,
): Promise<WorkOrderUplift> => {
const data = await apiPost<unknown>(
API_PATHS.workOrder.upliftCancel(workOrderId, upliftId),
{},
);
return mapWorkOrderUplift(handleApiResponse(data));
},
revoke: async (
workOrderId: string | number,
upliftId: string | number,
reason?: string,
): Promise<WorkOrderUplift> => {
const data = await apiPost<unknown>(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), {
reason,
});
return mapWorkOrderUplift(handleApiResponse(data));
},
};

View file

@ -1,4 +1,5 @@
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import {
DOC_STATUS_API,
LIFECYCLE_STATUS_API_TO_FE,
@ -319,8 +320,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const locationIdRaw = item.locationId ?? item.LocationId;
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
const upliftSummary = readUpliftSummary(item);
const hasPendingUplift = readHasPendingUplift(item, upliftSummary);
const boardUpliftSummary = readUpliftSummary(item);
const hasPendingUplift = readHasPendingUplift(item, boardUpliftSummary);
const upliftSummary = boardUpliftSummary == null ? undefined : mapUpliftSummary(item);
const row: WorkOrderTableRow = {
id: readId(item),

View file

@ -0,0 +1,158 @@
import type {
UpliftSummary,
WorkOrderUplift,
WorkOrderUpliftStatus,
} from "@/domain/work-orders/types/work-order-uplift";
import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return String(value);
}
}
return "";
}
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "number") {
return value;
}
if (typeof value === "string" && value !== "") {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
}
return null;
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") {
return value;
}
}
return false;
}
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
if (typeof value === "string" || typeof value === "number") {
return value;
}
return "";
}
function readOptionalId(
record: Record<string, unknown>,
...keys: string[]
): string | number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" || typeof value === "number") {
return value;
}
}
return null;
}
const STATUS_SET = new Set<string>(WORK_ORDER_UPLIFT_STATUSES);
function normalizeUpliftStatus(raw: string): WorkOrderUpliftStatus {
const normalized = raw.trim().toLowerCase().replace(/\s+/g, "_");
if (STATUS_SET.has(normalized)) {
return normalized as WorkOrderUpliftStatus;
}
if (normalized === "canceled") {
return "cancelled";
}
if (normalized === "autoapproved") {
return "auto_approved";
}
return "pending";
}
export function mapUpliftSummary(raw: unknown): UpliftSummary {
const item = asRecord(raw);
const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
const hasNested = Object.keys(nested).length > 0;
const source = hasNested ? nested : item;
const pendingCount =
readNumber(source, "pendingCount", "PendingCount") ??
readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ??
0;
const hasUplift =
readBool(source, "hasUplift", "HasUplift") ||
readBool(item, "hasUplift", "HasUplift") ||
pendingCount > 0;
// Never treat the work-order lifecycle status/Status as uplift primaryStatus.
const primaryStatusRaw = hasNested
? readString(source, "primaryStatus", "PrimaryStatus", "status", "Status")
: readString(item, "primaryStatus", "PrimaryStatus");
const amount = hasNested
? readNumber(source, "amount", "Amount")
: readNumber(item, "amount", "Amount");
const summary: UpliftSummary = {
hasUplift,
pendingCount,
};
if (primaryStatusRaw) {
summary.primaryStatus = normalizeUpliftStatus(primaryStatusRaw);
}
if (amount != null) {
summary.amount = amount;
}
return summary;
}
export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift {
const item = asRecord(raw);
const statusRaw = readString(item, "status", "Status");
return {
id: readId(item),
status: statusRaw ? normalizeUpliftStatus(statusRaw) : "pending",
amount: readNumber(item, "amount", "Amount", "requestedNTE", "RequestedNTE") ?? 0,
notes: readString(item, "notes", "Notes", "vendorReason", "VendorReason"),
requestedAt: readString(item, "requestedAt", "RequestedAt"),
requestedById: readOptionalId(
item,
"requestedById",
"RequestedById",
"requesterId",
"RequesterId",
),
requestedByName: readString(
item,
"requestedByName",
"RequestedByName",
"requestedByVendorName",
"RequestedByVendorName",
),
decidedAt: readString(item, "decidedAt", "DecidedAt"),
decidedByName: readString(item, "decidedByName", "DecidedByName"),
decisionNote: readString(item, "decisionNote", "DecisionNote"),
};
}
export function mapWorkOrderUplifts(rawItems: unknown[]): WorkOrderUplift[] {
return rawItems.map(mapWorkOrderUplift);
}

View file

@ -1,5 +1,6 @@
import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
import type { UpliftSummary } from "./work-order-uplift";
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
@ -65,7 +66,7 @@ export interface WorkOrderTableRow {
dispatchRowVersion: string;
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
flagColor: string | null;
upliftSummary?: WorkOrderUpliftSummary | null;
upliftSummary?: UpliftSummary | null;
hasPendingUplift?: boolean;
}

View file

@ -0,0 +1,41 @@
export const WORK_ORDER_UPLIFT_STATUSES = [
"pending",
"approved",
"auto_approved",
"rejected",
"cancelled",
"revoked",
] as const;
export type WorkOrderUpliftStatus = (typeof WORK_ORDER_UPLIFT_STATUSES)[number];
export interface UpliftSummary {
hasUplift: boolean;
pendingCount: number;
primaryStatus?: WorkOrderUpliftStatus;
amount?: number;
}
export interface WorkOrderUplift {
id: string | number;
status: WorkOrderUpliftStatus;
amount: number;
notes: string;
requestedAt: string;
requestedById: string | number | null;
requestedByName: string;
decidedAt: string;
decidedByName: string;
decisionNote: string;
}
export interface CreateWorkOrderUpliftInput {
amount: number;
notes: string;
attachments?: File[];
}
export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = {
hasUplift: false,
pendingCount: 0,
};

View file

@ -0,0 +1,87 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
import { queryKeys } from "@/infra/query-key/query-key";
import { requireQueryParam } from "@/lib/query/require-query-param";
function invalidateUpliftQueries(
queryClient: ReturnType<typeof useQueryClient>,
workOrderId: string | number,
): void {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.uplifts(workOrderId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
}
export function useWorkOrderUplifts(
workOrderId: string | number | null | undefined,
): UseQueryResult<WorkOrderUplift[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
queryFn: () =>
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
enabled: workOrderId != null && workOrderId !== "",
});
}
export function useCreateWorkOrderUplift(
workOrderId: string | number,
): UseMutationResult<WorkOrderUplift, Error, CreateWorkOrderUpliftInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateWorkOrderUpliftInput) =>
workOrderUpliftsApi.create(workOrderId, input),
onSuccess: () => {
invalidateUpliftQueries(queryClient, workOrderId);
toast.success("Uplift request created");
},
onError: (error) => {
toast.error(error.message || "Failed to create uplift");
},
});
}
export function useCancelWorkOrderUplift(
workOrderId: string | number,
): UseMutationResult<WorkOrderUplift, Error, string | number> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (upliftId: string | number) => workOrderUpliftsApi.cancel(workOrderId, upliftId),
onSuccess: () => {
invalidateUpliftQueries(queryClient, workOrderId);
toast.success("Uplift cancelled");
},
onError: (error) => {
toast.error(error.message || "Failed to cancel uplift");
},
});
}
export function useRevokeWorkOrderUplift(
workOrderId: string | number,
): UseMutationResult<WorkOrderUplift, Error, { upliftId: string | number; reason?: string }> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ upliftId, reason }: { upliftId: string | number; reason?: string }) =>
workOrderUpliftsApi.revoke(workOrderId, upliftId, reason),
onSuccess: () => {
invalidateUpliftQueries(queryClient, workOrderId);
toast.success("Uplift revoked");
},
onError: (error) => {
toast.error(error.message || "Failed to revoke uplift");
},
});
}

View file

@ -0,0 +1,130 @@
import type {
WorkOrderUplift,
UpliftSummary,
WorkOrderUpliftStatus,
} from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
export function formatUpliftMoney(value: number | null | undefined): string {
if (value == null || Number.isNaN(value)) {
return "—";
}
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(value);
}
const STATUS_LABELS: Record<WorkOrderUpliftStatus, string> = {
pending: "Pending",
approved: "Approved",
auto_approved: "Auto",
rejected: "Rejected",
cancelled: "Cancelled",
revoked: "Revoked",
};
export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string {
return STATUS_LABELS[status];
}
export function getUpliftCellLabel(summary: UpliftSummary): string | null {
if (!summary.hasUplift && summary.pendingCount <= 0) {
return null;
}
if (summary.pendingCount > 0) {
return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`;
}
if (summary.primaryStatus) {
const label = formatUpliftStatusLabel(summary.primaryStatus);
if (summary.amount != null) {
return `${label} ${formatUpliftMoney(summary.amount)}`;
}
return label;
}
if (summary.hasUplift) {
return "Uplift";
}
return null;
}
export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): {
background: string;
color: string;
} {
switch (status) {
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)" };
case "rejected":
return { background: "#FEE2E2", color: "#B91C1C" };
case "cancelled":
case "revoked":
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
default:
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
}
}
export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): boolean {
return status === "Completed" || status === "Canceled";
}
export function canOpenUpliftsDialog(
summary: UpliftSummary,
status: WorkOrderTableRow["status"],
): boolean {
if (summary.hasUplift || summary.pendingCount > 0) {
return true;
}
return !isWorkOrderUpliftsReadOnly(status);
}
export function canRevokeWorkOrderUplift(
uplift: Pick<WorkOrderUplift, "status" | "requestedById">,
currentUserId: string | number | null | undefined,
): boolean {
if (uplift.status !== "auto_approved") {
// SH-214/SH-212: admin-approved revoke lives on Uplift Approvals, not the WO dialog.
return false;
}
return (
currentUserId != null &&
currentUserId !== "" &&
uplift.requestedById != null &&
String(uplift.requestedById) === String(currentUserId)
);
}
export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean {
return status === "approved";
}
export const UPLIFT_STANDARD_ALLOWANCE_USD = 500;
export const UPLIFT_EMERGENCY_ALLOWANCE_USD = 5000;
export function getUpliftAllowanceCap(workOrderType: string): number {
return workOrderType === "Emergency"
? UPLIFT_EMERGENCY_ALLOWANCE_USD
: UPLIFT_STANDARD_ALLOWANCE_USD;
}
export function hasOpenWorkOrderUplift(
uplifts: ReadonlyArray<Pick<WorkOrderUplift, "status">>,
): boolean {
return uplifts.some((uplift) => uplift.status === "pending");
}
export function getRemainingUpliftAllowance(
workOrderType: string,
uplifts: ReadonlyArray<Pick<WorkOrderUplift, "status" | "amount">>,
): number {
const consumed = uplifts
.filter((uplift) => uplift.status === "auto_approved")
.reduce((sum, uplift) => sum + uplift.amount, 0);
return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed);
}

View file

@ -26,6 +26,8 @@ export const queryKeys = {
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
uplifts: (workOrderId: string | number) =>
[...queryKeys.workOrders.all, "uplifts", workOrderId] as const,
},
pmSchedules: {
all: ["pmSchedules"] as const,

View file

@ -4,3 +4,13 @@ export function getPrimaryUserRole(
): string {
return userRoles?.split(",")[0]?.trim() || fallback;
}
export function isAdminUser(userRoles: string | null | undefined): boolean {
if (!userRoles) {
return false;
}
return userRoles
.split(",")
.map((role) => role.trim().toLowerCase())
.includes("admin");
}

View file

@ -73,6 +73,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
return {
...defaults,

View file

@ -85,6 +85,7 @@ function baseRow(): WorkOrderTableRow {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
}

View file

@ -60,6 +60,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
return {
...defaults,

View file

@ -50,6 +50,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}

View file

@ -54,6 +54,7 @@ const unscheduledRow: WorkOrderTableRow = {
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
const handlers: WoTableRowHandlers = {

View file

@ -93,6 +93,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}

View file

@ -0,0 +1,177 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell";
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";
const pendingUplift: WorkOrderUplift = {
id: 1,
status: "pending",
amount: 450,
notes: "Extra work",
requestedAt: "2026-07-20T10:00:00Z",
requestedById: "vendor-1",
requestedByName: "Vendor",
decidedAt: "",
decidedByName: "",
decisionNote: "",
};
const mockState = vi.hoisted(() => ({
uplifts: [] as WorkOrderUplift[],
}));
const baseRow: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "CHI-1042",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Alex",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "2026-07-20",
dayGroup: "2026-07-20",
dayLabel: "Mon",
location: "Chicago Loop",
locationId: "loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
vendorId: "1",
company: "Vendor Co",
tech: "Sam",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: { hasUplift: true, pendingCount: 1, primaryStatus: "pending", amount: 450 },
};
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({
data: mockState.uplifts,
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useCreateWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
}));
describe("UpliftCell affordances", () => {
it("renders pending pill and opens handler when onOpen is provided", () => {
const onOpen = vi.fn();
renderWithProviders(
<UpliftCell summary={{ hasUplift: true, pendingCount: 1 }} onOpen={onOpen} />,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /1 pending/i }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("shows read-only dash when onOpen is omitted", () => {
renderWithProviders(<UpliftCell summary={{ hasUplift: false, pendingCount: 0 }} />, {
withAuth: false,
});
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
});
});
describe("WorkOrderUpliftsDialog affordances", () => {
it("hides create form when a pending request already exists", () => {
mockState.uplifts = [pendingUplift];
renderWithProviders(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
withAuth: true,
});
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();
});
it("shows create form with remaining allowance when no pending request exists", () => {
mockState.uplifts = [];
renderWithProviders(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
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", () => {
mockState.uplifts = [pendingUplift];
renderWithProviders(
<WorkOrderUpliftsDialog row={{ ...baseRow, status: "Completed" }} open onClose={vi.fn()} />,
{ 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();
});
});
describe("WorkOrderUpliftListItem revoke affordances", () => {
it("hides Revoke for admin-approved uplifts on the Work Order surface", () => {
renderWithProviders(
<WorkOrderUpliftListItem
uplift={{ ...pendingUplift, status: "approved" }}
readOnly={false}
currentUserId="admin-1"
onRevoke={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument();
});
it("shows Revoke for the requesting dispatcher on auto-approved uplifts", () => {
renderWithProviders(
<WorkOrderUpliftListItem
uplift={{ ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" }}
readOnly={false}
currentUserId="dispatcher-1"
onRevoke={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument();
});
});

View file

@ -112,6 +112,7 @@ const canceledRow: WorkOrderTableRow = {
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({

View file

@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
}));
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
describe("workOrderUpliftsApi", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
});
it("propagates a real API list failure instead of returning mock uplifts", async () => {
const error = new Error("Unauthorized");
apiGet.mockRejectedValueOnce(error);
await expect(workOrderUpliftsApi.list(42)).rejects.toBe(error);
});
});

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
mapUpliftSummary,
mapWorkOrderUplift,
} from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import {
getUpliftCellLabel,
getUpliftStatusPillStyle,
} from "@/domain/work-orders/utils/uplift-display-utils";
describe("mapUpliftSummary", () => {
it("maps nested PascalCase upliftSummary from board row payload", () => {
const result = mapUpliftSummary({
id: 10,
UpliftSummary: {
HasUplift: true,
PendingCount: 2,
PrimaryStatus: "Pending",
Amount: 500,
},
});
expect(result).toEqual({
hasUplift: true,
pendingCount: 2,
primaryStatus: "pending",
amount: 500,
});
});
it("derives hasUplift from pendingUpliftCount when nested summary is absent", () => {
const result = mapUpliftSummary({
pendingUpliftCount: 1,
});
expect(result.hasUplift).toBe(true);
expect(result.pendingCount).toBe(1);
});
it("does not treat work-order status as uplift primaryStatus when nested summary is absent", () => {
const result = mapUpliftSummary({
status: "Cancelled",
Status: "Cancelled",
hasUplift: false,
});
expect(result.hasUplift).toBe(false);
expect(result.primaryStatus).toBeUndefined();
expect(getUpliftCellLabel(result)).toBeNull();
});
it("normalizes auto-approved and canceled status aliases", () => {
expect(mapUpliftSummary({ primaryStatus: "AutoApproved" }).primaryStatus).toBe("auto_approved");
expect(mapWorkOrderUplift({ status: "Canceled" }).status).toBe("cancelled");
});
it("maps requester identity for revoke ownership checks", () => {
expect(mapWorkOrderUplift({ requestedById: "dispatcher-1" }).requestedById).toBe(
"dispatcher-1",
);
});
});
describe("uplift display utils", () => {
it("builds pending pill label and style", () => {
const summary = { hasUplift: true, pendingCount: 1, primaryStatus: "pending" as const };
expect(getUpliftCellLabel(summary)).toBe("1 Pending");
expect(getUpliftStatusPillStyle("pending")).toEqual({
background: "var(--status-pending-bg)",
color: "var(--status-pending-text)",
});
});
it("includes amount in non-pending primary status label", () => {
expect(
getUpliftCellLabel({
hasUplift: true,
pendingCount: 0,
primaryStatus: "approved",
amount: 1200,
}),
).toBe("Approved $1,200");
});
it("hides Pending/Cancelled when hasUplift is false", () => {
expect(
getUpliftCellLabel({
hasUplift: false,
pendingCount: 0,
primaryStatus: "pending",
}),
).toBeNull();
expect(
getUpliftCellLabel({
hasUplift: false,
pendingCount: 0,
primaryStatus: "cancelled",
}),
).toBeNull();
});
});

View file

@ -98,7 +98,8 @@ const BASE_ROW: WorkOrderTableRow = {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
};
upliftSummary: { hasUplift: false, pendingCount: 0 },
} as WorkOrderTableRow;
describe("useWorkOrderTableMutations completion-doc persistence", () => {
let serverState: ServerState;

View file

@ -4,6 +4,7 @@ import {
isSlideOverDraftDirty,
SLIDE_OVER_EDIT_KEYS,
} from "@/domain/work-orders/utils/slide-over-draft";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
@ -52,6 +53,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "1",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: EMPTY_UPLIFT_SUMMARY,
...overrides,
};
}

View file

@ -74,6 +74,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
@ -69,6 +70,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "AQAAAAAAAAA=",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: EMPTY_UPLIFT_SUMMARY,
...overrides,
};
}

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
canRevokeWorkOrderUplift,
getRemainingUpliftAllowance,
hasOpenWorkOrderUplift,
upliftRevokeRequiresReason,
} from "@/domain/work-orders/utils/uplift-display-utils";
describe("canRevokeWorkOrderUplift", () => {
it("allows a dispatcher to revoke their own auto-approved uplift", () => {
expect(
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "dispatcher-1" },
"dispatcher-1",
),
).toBe(true);
});
it("rejects a dispatcher revoking another user's auto-approved uplift", () => {
expect(
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "other-dispatcher" },
"dispatcher-1",
),
).toBe(false);
});
it("rejects admin revoking auto-approved uplift they did not request", () => {
expect(
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "dispatcher-1" },
"admin-user",
),
).toBe(false);
});
it("allows admin to revoke their own auto-approved uplift as the requester", () => {
expect(
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "admin-user" },
"admin-user",
),
).toBe(true);
});
it("rejects approved revoke on the Work Order surface (SH-214)", () => {
const uplift = { status: "approved" as const, requestedById: "dispatcher-1" };
expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1")).toBe(false);
expect(canRevokeWorkOrderUplift(uplift, "admin-user")).toBe(false);
});
it("rejects revoke for other statuses", () => {
expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1")).toBe(false);
expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1")).toBe(false);
});
});
describe("upliftRevokeRequiresReason", () => {
it("requires reason only for approved uplifts", () => {
expect(upliftRevokeRequiresReason("approved")).toBe(true);
expect(upliftRevokeRequiresReason("auto_approved")).toBe(false);
});
});
describe("SH-196 allowance helpers", () => {
it("uses a $500 cap for non-emergency work orders", () => {
expect(getRemainingUpliftAllowance("PM", [])).toBe(500);
expect(getRemainingUpliftAllowance("PM", [{ status: "auto_approved", amount: 400 }])).toBe(100);
});
it("uses a $5,000 cap for emergency work orders", () => {
expect(getRemainingUpliftAllowance("Emergency", [])).toBe(5000);
});
it("ignores revoked and pending amounts when computing remaining allowance", () => {
expect(
getRemainingUpliftAllowance("PM", [
{ status: "auto_approved", amount: 200 },
{ status: "revoked", amount: 300 },
{ status: "pending", amount: 400 },
]),
).toBe(300);
});
it("detects an open pending request", () => {
expect(hasOpenWorkOrderUplift([{ status: "pending" }])).toBe(true);
expect(hasOpenWorkOrderUplift([{ status: "auto_approved" }])).toBe(false);
});
});

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { isAdminUser } from "@/lib/auth/user-utils";
describe("isAdminUser", () => {
it("returns true when Admin appears in roles", () => {
expect(isAdminUser("Admin")).toBe(true);
expect(isAdminUser("Dispatcher,Admin")).toBe(true);
expect(isAdminUser(" admin ")).toBe(true);
});
it("returns false for non-admin roles", () => {
expect(isAdminUser("Dispatcher")).toBe(false);
expect(isAdminUser(undefined)).toBe(false);
});
});