mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
Merge branch 'dev' into feature/wo-advanced-filters-parity
This commit is contained in:
commit
d534e92d03
42 changed files with 1584 additions and 156 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ export function WoTableInlineRowCells({
|
|||
|
||||
<InlineMutedDashCell />
|
||||
<InlineMutedDashCell />
|
||||
<InlineMutedDashCell />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
61
src/domain/work-orders/api/work-order-uplifts-api.ts
Normal file
61
src/domain/work-orders/api/work-order-uplifts-api.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
158
src/domain/work-orders/mappers/work-order-uplift-mapper.ts
Normal file
158
src/domain/work-orders/mappers/work-order-uplift-mapper.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
41
src/domain/work-orders/types/work-order-uplift.ts
Normal file
41
src/domain/work-orders/types/work-order-uplift.ts
Normal 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,
|
||||
};
|
||||
87
src/domain/work-orders/use-cases/use-work-order-uplifts.ts
Normal file
87
src/domain/work-orders/use-cases/use-work-order-uplifts.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
}
|
||||
130
src/domain/work-orders/utils/uplift-display-utils.ts
Normal file
130
src/domain/work-orders/utils/uplift-display-utils.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ function baseRow(): WorkOrderTableRow {
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const unscheduledRow: WorkOrderTableRow = {
|
|||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
};
|
||||
|
||||
const handlers: WoTableRowHandlers = {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
|||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
15
src/test/lib/auth/user-utils.test.ts
Normal file
15
src/test/lib/auth/user-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue