mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 19:32:06 +00:00
Merge branch 'dev' into feature/wo-multi-poc-contacts
This commit is contained in:
commit
a75b094564
42 changed files with 1584 additions and 156 deletions
|
|
@ -38,6 +38,11 @@ export const API_PATHS = {
|
||||||
mediaItem: (id: string | number, mediaId: string | number) =>
|
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||||
`workorders/${id}/media/${mediaId}`,
|
`workorders/${id}/media/${mediaId}`,
|
||||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
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",
|
lookupsDispatchers: "workorders/lookups/dispatchers",
|
||||||
},
|
},
|
||||||
pmSchedule: {
|
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;
|
onWeekChange: (mondayIso: string) => void;
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||||
onScheduleHighlight: (id: string | number | null) => void;
|
onScheduleHighlight: (id: string | number | null) => void;
|
||||||
|
|
@ -28,6 +29,7 @@ export function useWoTableRowHandlers({
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onPatch,
|
onPatch,
|
||||||
onScheduleHighlight,
|
onScheduleHighlight,
|
||||||
|
|
@ -62,6 +64,12 @@ export function useWoTableRowHandlers({
|
||||||
onOpenCompDoc(row);
|
onOpenCompDoc(row);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
onOpenUplifts: onOpenUplifts
|
||||||
|
? (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenUplifts(row);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => {
|
onOpenMedia: (row: WorkOrderTableRow) => {
|
||||||
tableData.setSelectedId(row.id);
|
tableData.setSelectedId(row.id);
|
||||||
onOpenMedia(row);
|
onOpenMedia(row);
|
||||||
|
|
@ -136,6 +144,7 @@ export function useWoTableRowHandlers({
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onPatch,
|
onPatch,
|
||||||
onScheduleHighlight,
|
onScheduleHighlight,
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ export const WO_TABLE_COLS = [
|
||||||
{ key: "appt", label: "APPT TIME", w: 128 },
|
{ key: "appt", label: "APPT TIME", w: 128 },
|
||||||
{ key: "status", label: "STATUS", w: 148 },
|
{ key: "status", label: "STATUS", w: 148 },
|
||||||
{ key: "doc", label: "COMP DOC", w: 84 },
|
{ key: "doc", label: "COMP DOC", w: 84 },
|
||||||
|
{ key: "uplift", label: "UPLIFT", w: 108 },
|
||||||
{ key: "completed", label: "COMPLETED DATE", w: 130 },
|
{ key: "completed", label: "COMPLETED DATE", w: 130 },
|
||||||
{ key: "actions", label: "", w: 100 },
|
{ key: "actions", label: "", w: 100 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const WO_TABLE_MIN_WIDTH = 1634;
|
export const WO_TABLE_MIN_WIDTH = 1742;
|
||||||
export const WO_TABLE_ROW_HEIGHT = 52;
|
export const WO_TABLE_ROW_HEIGHT = 52;
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,7 @@ export function WoTableInlineRowCells({
|
||||||
|
|
||||||
<InlineMutedDashCell />
|
<InlineMutedDashCell />
|
||||||
<InlineMutedDashCell />
|
<InlineMutedDashCell />
|
||||||
|
<InlineMutedDashCell />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import { CompDocCell } from "./cells/comp-doc-cell";
|
||||||
import { DateCell } from "./cells/date-cell";
|
import { DateCell } from "./cells/date-cell";
|
||||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
import { StatusCell } from "./cells/status-cell";
|
import { StatusCell } from "./cells/status-cell";
|
||||||
|
import { UpliftCell } from "./cells/uplift-cell";
|
||||||
import { VendorCell } from "./cells/vendor-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";
|
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||||
|
|
||||||
type WoTableRowServiceCellsProps = {
|
type WoTableRowServiceCellsProps = {
|
||||||
|
|
@ -103,6 +106,17 @@ export function WoTableRowServiceCells({
|
||||||
onOpenMedia={() => handlers.onOpenMedia(row)}
|
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</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
|
<td
|
||||||
className="group/cell"
|
className="group/cell"
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export type WoTableRowHandlers = {
|
||||||
onView: (row: WorkOrderTableRow) => void;
|
onView: (row: WorkOrderTableRow) => void;
|
||||||
onEdit: (row: WorkOrderTableRow) => void;
|
onEdit: (row: WorkOrderTableRow) => void;
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
|
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
|
||||||
onScheduleDate: (id: string | number, date: string) => void;
|
onScheduleDate: (id: string | number, date: string) => void;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ type WoTableProps = {
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onClearSearch?: () => void;
|
onClearSearch?: () => void;
|
||||||
onOpenAdvancedFilters?: () => void;
|
onOpenAdvancedFilters?: () => void;
|
||||||
|
|
@ -49,6 +50,7 @@ export function WoTable({
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onClearSearch,
|
onClearSearch,
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
|
|
@ -71,6 +73,7 @@ export function WoTable({
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onPatch,
|
onPatch,
|
||||||
onScheduleHighlight: setDueDateHighlightId,
|
onScheduleHighlight: setDueDateHighlightId,
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,22 @@
|
||||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
type WorkOrdersListOverlaysProps = {
|
type WorkOrdersListOverlaysProps = {
|
||||||
wizardOpen: boolean;
|
activeUpliftsRow: WorkOrderTableRow | null;
|
||||||
onWizardOpenChange: (open: boolean) => void;
|
onCloseUplifts: () => 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrdersListOverlays({
|
export function WorkOrdersListOverlays({
|
||||||
wizardOpen,
|
activeUpliftsRow,
|
||||||
onWizardOpenChange,
|
onCloseUplifts,
|
||||||
users,
|
|
||||||
sites,
|
|
||||||
pmTypes,
|
|
||||||
vendorTechs,
|
|
||||||
advOpen,
|
|
||||||
onAdvOpenChange,
|
|
||||||
advInitial,
|
|
||||||
onApplyAdvanced,
|
|
||||||
onClearAdvanced,
|
|
||||||
activeDocRow,
|
|
||||||
onDocOpenChange,
|
|
||||||
completionDocs,
|
|
||||||
onSaveCompDoc,
|
|
||||||
onDateCompletedChange,
|
|
||||||
activeMediaRow,
|
|
||||||
onMediaOpenChange,
|
|
||||||
}: WorkOrdersListOverlaysProps) {
|
}: WorkOrdersListOverlaysProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WorkOrdersAdvancedSearchSheet
|
{activeUpliftsRow != null && (
|
||||||
open={advOpen}
|
<WorkOrderUpliftsDialog
|
||||||
onOpenChange={onAdvOpenChange}
|
row={activeUpliftsRow}
|
||||||
initial={advInitial}
|
open={Boolean(activeUpliftsRow)}
|
||||||
onApply={onApplyAdvanced}
|
onClose={onCloseUplifts}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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 [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [mediaRow, setMediaRow] = 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 [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||||
const filters = useWorkOrdersListFilters();
|
const filters = useWorkOrdersListFilters();
|
||||||
const tableData = useWorkOrdersTableData(filters);
|
const tableData = useWorkOrdersTableData(filters);
|
||||||
|
|
@ -62,6 +63,11 @@ export function useWorkOrdersListPage() {
|
||||||
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||||
}, [mediaRow, tableData.rows]);
|
}, [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) => {
|
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
||||||
setSlideOverRow(row);
|
setSlideOverRow(row);
|
||||||
setSlideOverEdit(edit);
|
setSlideOverEdit(edit);
|
||||||
|
|
@ -107,6 +113,7 @@ export function useWorkOrdersListPage() {
|
||||||
setDocRow,
|
setDocRow,
|
||||||
mediaRow,
|
mediaRow,
|
||||||
setMediaRow,
|
setMediaRow,
|
||||||
|
setUpliftsRow,
|
||||||
completionDocs,
|
completionDocs,
|
||||||
filters,
|
filters,
|
||||||
tableData,
|
tableData,
|
||||||
|
|
@ -122,6 +129,7 @@ export function useWorkOrdersListPage() {
|
||||||
activeSlideOverRow,
|
activeSlideOverRow,
|
||||||
activeDocRow,
|
activeDocRow,
|
||||||
activeMediaRow,
|
activeMediaRow,
|
||||||
|
activeUpliftsRow,
|
||||||
handleOpenSlideOver,
|
handleOpenSlideOver,
|
||||||
handleCloseSlideOver,
|
handleCloseSlideOver,
|
||||||
handleConfirmCancel,
|
handleConfirmCancel,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
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 { 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 { 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 { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||||
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
|
||||||
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
|
|
||||||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||||
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
@ -17,7 +12,7 @@ import { useAuthContext } from "@/providers/auth-context";
|
||||||
export default function WorkOrdersListPage() {
|
export default function WorkOrdersListPage() {
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const page = useWorkOrdersListPage();
|
const page = useWorkOrdersListPage();
|
||||||
const { filters, tableData, activeDocRow } = page;
|
const { filters, tableData } = page;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||||
|
|
@ -86,79 +81,13 @@ export default function WorkOrdersListPage() {
|
||||||
onOpenSlideOver={page.handleOpenSlideOver}
|
onOpenSlideOver={page.handleOpenSlideOver}
|
||||||
onOpenCompDoc={page.setDocRow}
|
onOpenCompDoc={page.setDocRow}
|
||||||
onOpenMedia={page.setMediaRow}
|
onOpenMedia={page.setMediaRow}
|
||||||
|
onOpenUplifts={page.setUpliftsRow}
|
||||||
onClearSearch={() => filters.handleSearchChange("")}
|
onClearSearch={() => filters.handleSearchChange("")}
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
onPatch={page.patchField}
|
onPatch={page.patchField}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkOrdersListWizard
|
<WorkOrdersListPagePanels page={page} />
|
||||||
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 });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</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,5 +1,6 @@
|
||||||
import { readAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
import { readAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||||
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||||
|
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
|
||||||
import {
|
import {
|
||||||
DOC_STATUS_API,
|
DOC_STATUS_API,
|
||||||
LIFECYCLE_STATUS_API_TO_FE,
|
LIFECYCLE_STATUS_API_TO_FE,
|
||||||
|
|
@ -320,8 +321,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
const locationIdRaw = item.locationId ?? item.LocationId;
|
const locationIdRaw = item.locationId ?? item.LocationId;
|
||||||
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
||||||
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
||||||
const upliftSummary = readUpliftSummary(item);
|
const boardUpliftSummary = readUpliftSummary(item);
|
||||||
const hasPendingUplift = readHasPendingUplift(item, upliftSummary);
|
const hasPendingUplift = readHasPendingUplift(item, boardUpliftSummary);
|
||||||
|
const upliftSummary = boardUpliftSummary == null ? undefined : mapUpliftSummary(item);
|
||||||
|
|
||||||
const row: WorkOrderTableRow = {
|
const row: WorkOrderTableRow = {
|
||||||
id: readId(item),
|
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,6 +1,7 @@
|
||||||
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
||||||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||||
import type { WorkOrderSeverity } from "./work-order-severity";
|
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||||
|
import type { UpliftSummary } from "./work-order-uplift";
|
||||||
|
|
||||||
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||||
|
|
||||||
|
|
@ -67,7 +68,7 @@ export interface WorkOrderTableRow {
|
||||||
dispatchRowVersion: string;
|
dispatchRowVersion: string;
|
||||||
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
||||||
flagColor: string | null;
|
flagColor: string | null;
|
||||||
upliftSummary?: WorkOrderUpliftSummary | null;
|
upliftSummary?: UpliftSummary | null;
|
||||||
hasPendingUplift?: boolean;
|
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,
|
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
||||||
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
||||||
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
||||||
|
uplifts: (workOrderId: string | number) =>
|
||||||
|
[...queryKeys.workOrders.all, "uplifts", workOrderId] as const,
|
||||||
},
|
},
|
||||||
pmSchedules: {
|
pmSchedules: {
|
||||||
all: ["pmSchedules"] as const,
|
all: ["pmSchedules"] as const,
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,13 @@ export function getPrimaryUserRole(
|
||||||
): string {
|
): string {
|
||||||
return userRoles?.split(",")[0]?.trim() || fallback;
|
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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ function baseRow(): WorkOrderTableRow {
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ const unscheduledRow: WorkOrderTableRow = {
|
||||||
rowVersion: "",
|
rowVersion: "",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlers: WoTableRowHandlers = {
|
const handlers: WoTableRowHandlers = {
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
...overrides,
|
...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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -113,6 +113,7 @@ const canceledRow: WorkOrderTableRow = {
|
||||||
rowVersion: "",
|
rowVersion: "",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -99,7 +99,8 @@ const BASE_ROW: WorkOrderTableRow = {
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
};
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
|
} as WorkOrderTableRow;
|
||||||
|
|
||||||
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||||
let serverState: ServerState;
|
let serverState: ServerState;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
isSlideOverDraftDirty,
|
isSlideOverDraftDirty,
|
||||||
SLIDE_OVER_EDIT_KEYS,
|
SLIDE_OVER_EDIT_KEYS,
|
||||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
} 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";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
|
@ -53,6 +54,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
rowVersion: "1",
|
rowVersion: "1",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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 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 { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
||||||
|
|
@ -70,6 +71,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
rowVersion: "AQAAAAAAAAA=",
|
rowVersion: "AQAAAAAAAAA=",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||||
...overrides,
|
...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