From b4cb9d02888b11724a59a145a6223d30ee7a0a57 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 14 Aug 2026 11:59:52 -0300 Subject: [PATCH] feat(work-orders): severity gates and wizard type/WO# parity (SH-192) (#106) * fix(work-orders): allow blank WO number in wizard create path [recover] remove malicious eslint payload (was d0d08508) * fix(work-orders): emit severity before type in board scalar PATCH ops Co-authored-by: Cursor * chore(work-orders): drop vendor collateral from SH-192 lane * fix(work-orders): add severity to unscheduled highlight fixture after dev merge * refactor(work-orders): extract inline type/severity cell under function line cap * fix(work-orders): add severity to comp-doc and media fixtures after dev merge PR #45 introduced completion-doc, media and table-mutation fixtures that predate the required severity field, so the integrated head failed tsc. Fill severity on those row builders (null for PM, SEV 1 for the Emergency fixture) and type the mutation BASE_ROW instead of asserting it. * test(work-orders): align merge fixes with severity field and WO# placeholder --------- Co-authored-by: Cursor Co-authored-by: Arthur Bassi Co-authored-by: Alexandre Brandizzi --- .../table/build-inline-draft-create-input.ts | 1 + .../list/table/cells/severity-badge.tsx | 39 ++++ .../table/cells/severity-options-list.tsx | 86 +++++++ .../list/table/cells/type-cell.tsx | 64 ++++-- .../list/table/cells/type-severity-modal.tsx | 146 ++++++++++++ .../list/table/wo-table-inline-row-cells.tsx | 22 +- .../list/table/wo-table-row-cells.tsx | 7 +- .../_components/wizard/new-wo-wizard.tsx | 13 +- .../wizard/use-new-wo-wizard-draft.ts | 4 + .../wizard/use-new-wo-wizard-state.ts | 4 + .../_components/wizard/wizard-footer.tsx | 47 +++- .../wizard/wizard-severity-picker.tsx | 31 +++ .../wizard-step-location-poc-fields.tsx | 18 +- .../wizard/wizard-step-location-service.tsx | 4 +- .../wizard/wizard-step-type-schedule.tsx | 213 +++++++++++------- .../assign-wizard-service-poc-fields.ts | 4 + .../mappers/expand-scalar-board-ops.ts | 6 + .../mappers/wizard-draft-mapper.ts | 20 +- .../mappers/work-order-board-mapper.ts | 2 +- .../mappers/work-order-table-row-mapper.ts | 2 + .../work-orders/types/work-order-board.ts | 7 +- .../work-orders/types/work-order-severity.ts | 75 ++++++ .../work-orders/types/work-order-table-row.ts | 5 + .../work-orders/types/work-order-wizard.ts | 37 ++- ...mp-doc-dialog-completion-contract.test.tsx | 7 +- .../media-upload-modal-api.test.tsx | 1 + .../new-wo-wizard-duplicate.test.tsx | 4 +- .../use-comp-doc-dialog-address-seed.test.tsx | 7 +- .../use-slide-over-media-state.test.tsx | 1 + .../wo-table-unscheduled-highlight.test.tsx | 1 + ...work-order-slide-over-failed-save.test.tsx | 1 + ...ork-orders-board-core-affordances.test.tsx | 11 +- .../mappers/wizard-draft-mapper.test.ts | 103 +++++++-- .../mappers/work-order-board-mapper.test.ts | 14 +- .../work-order-board-patch-mapper.test.ts | 6 + .../mappers/work-order-mapper.test.ts | 2 + ...der-table-mutations-completed-date.test.ts | 1 + .../use-work-order-table-mutations.test.tsx | 5 +- .../utils/slide-over-draft.test.ts | 1 + .../slide-over-failed-save-baseline.test.ts | 1 + .../utils/table-filter-utils.test.ts | 1 + tmp/pr-descriptions/pr2-severity-wizard.md | 43 ++++ 42 files changed, 905 insertions(+), 162 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/type-severity-modal.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-severity-picker.tsx create mode 100644 src/domain/work-orders/types/work-order-severity.ts create mode 100644 tmp/pr-descriptions/pr2-severity-wizard.md diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 0edec077..539a8427 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -21,6 +21,7 @@ export function buildInlineDraftCreateInput( siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }), woNumber: draft.woNumber, type: draft.type || "PM", + severity: draft.severity, assigneeId: draft.dispatcherId, assigneeName: assignee?.name ?? "", scheduledOn: draft.scheduledOn, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx new file mode 100644 index 00000000..18e3092a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx @@ -0,0 +1,39 @@ +import { Tooltip } from "@mui/material"; + +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; + +type SeverityBadgeProps = { + severity: WorkOrderSeverity; + onClick?: (e: React.MouseEvent) => void; +}; + +export function SeverityBadge({ severity, onClick }: SeverityBadgeProps) { + const lvl = SEVERITY_LEVELS[severity]; + const color = SEVERITY_TOKEN_COLOR[lvl.token]; + const title = `${lvl.description} · ${lvl.deadline} · After hours: ${lvl.afterHours}`; + + return ( + + + {lvl.label} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx new file mode 100644 index 00000000..d78961af --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx @@ -0,0 +1,86 @@ +import { Text } from "@/components/ui/text"; +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + WORK_ORDER_SEVERITIES, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; + +type SeverityOptionsListProps = { + value: WorkOrderSeverity | null; + onSelect: (severity: WorkOrderSeverity) => void; +}; + +export function SeverityOptionsList({ value, onSelect }: SeverityOptionsListProps) { + return ( +
+ {WORK_ORDER_SEVERITIES.map((n) => { + const lvl = SEVERITY_LEVELS[n]; + const color = SEVERITY_TOKEN_COLOR[lvl.token]; + const selected = value === n; + return ( + + ); + })} +
+ ); +} + +type SeverityFieldProps = { + value: WorkOrderSeverity | null; + onSelect: (severity: WorkOrderSeverity) => void; +}; + +export function SeverityField({ value, onSelect }: SeverityFieldProps) { + return ( +
+ + Severity (from APM) + + + Transcribe the severity already assigned on the client's ticket — this is not a + judgment call. + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx index 4ac6fe60..7421cfac 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx @@ -1,25 +1,49 @@ import { useState } from "react"; import { Check } from "lucide-react"; - import { Popover } from "@mui/material"; +import { + typeNeedsSeverity, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; import { HoverChevron } from "./hover-chevron"; - +import { SeverityBadge } from "./severity-badge"; +import { TypeSeverityModal } from "./type-severity-modal"; import { WoTableTypeLabel } from "./wo-table-type-label"; type TypeCellProps = { value: WOType | ""; - + severity?: WorkOrderSeverity | null; q?: string; - - onSave: (v: WOType) => void; + onSave: (type: WOType, severity: WorkOrderSeverity | null) => void; }; -export function TypeCell({ value, q, onSave }: TypeCellProps) { +export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [modalSeed, setModalSeed] = useState(value); + const [resetSeverity, setResetSeverity] = useState(false); + + function pick(t: WOType) { + setAnchorEl(null); + const needsSeverity = typeNeedsSeverity(t); + const switchingSeverityType = + needsSeverity && value !== "" && typeNeedsSeverity(value) && t !== value; + + if (needsSeverity && (severity == null || switchingSeverityType)) { + setModalSeed(t); + setResetSeverity(Boolean(switchingSeverityType && severity != null)); + setModalOpen(true); + return; + } + + onSave(t, needsSeverity ? severity : null); + } + + const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null); return ( <> @@ -27,7 +51,6 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) { type="button" onClick={(e) => { e.stopPropagation(); - setAnchorEl(e.currentTarget); }} className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80" @@ -37,7 +60,17 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) { ) : ( Select … )} - + {Boolean(showSeverityBadge && severity != null) && ( + { + e.stopPropagation(); + setModalSeed(value); + setResetSeverity(false); + setModalOpen(true); + }} + /> + )} @@ -53,19 +86,22 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) { ))} + + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/type-severity-modal.tsx b/src/app/(protected)/workorders/_components/list/table/cells/type-severity-modal.tsx new file mode 100644 index 00000000..1d9ded63 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/type-severity-modal.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; + +import { Text } from "@/components/ui/text"; +import { + typeNeedsSeverity, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; +import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; + +import { SeverityField } from "./severity-options-list"; +import { WoTableTypeLabel } from "./wo-table-type-label"; + +type TypeSeverityModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + type: WOType | ""; + severity: WorkOrderSeverity | null; + onSave: (type: WOType, severity: WorkOrderSeverity | null) => void; +}; + +export function TypeSeverityModal({ + open, + onOpenChange, + type, + severity, + onSave, +}: TypeSeverityModalProps) { + const [draftType, setDraftType] = useState(type); + const [draftSeverity, setDraftSeverity] = useState(severity); + + useEffect(() => { + if (open) { + setDraftType(type); + setDraftSeverity(severity); + } + }, [open, type, severity]); + + const needsSeverity = typeNeedsSeverity(draftType); + + function pickType(t: WOType) { + if (typeNeedsSeverity(t)) { + if (t !== draftType) { + setDraftSeverity(null); + } + setDraftType(t); + return; + } + onSave(t, null); + onOpenChange(false); + } + + function confirm() { + if (!draftType || (needsSeverity && draftSeverity == null)) { + return; + } + onSave(draftType, needsSeverity ? draftSeverity : null); + onOpenChange(false); + } + + const canConfirm = Boolean(draftType) && (!needsSeverity || draftSeverity != null); + + return ( + onOpenChange(false)} + maxWidth="xs" + fullWidth + onClick={(e) => e.stopPropagation()} + slotProps={{ + paper: { + sx: { borderRadius: 2, background: "var(--color-bg-surface)" }, + }, + }} + > + + Type & severity + + +
+
+ + Type + +
+ {WO_TYPES.map((t) => ( + + ))} +
+
+ {Boolean(needsSeverity) && ( + + )} +
+
+ + + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index 28b01db3..66fce868 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -37,6 +37,24 @@ type WoTableInlineRowCellsProps = { onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; +function InlineTypeSeverityCell({ + draft, + onPatch, +}: { + draft: WorkOrderTableDraft; + onPatch: (patch: Partial) => void; +}) { + return ( + + onPatch({ type: t, severity: sev })} + /> + + ); +} + function InlineMutedDashCell() { return ( @@ -81,9 +99,7 @@ export function WoTableInlineRowCells({ onDuplicateFound={onDuplicateFound} /> - - onPatch({ type: t })} /> - + e.stopPropagation()} > - onPatchRow({ type: t })} /> + onPatchRow({ type: t, severity: sev })} + /> ({ id: p.id, value: p.value }))} + showErrors={wizard.showErrors} svcOpen={wizard.svcOpen} onSvcOpenChange={wizard.setSvcOpen} svcSearch={wizard.svcSearch} @@ -108,8 +110,15 @@ export function NewWoWizard({ onCancel={() => { if (!wizard.isCreating) onOpenChange(false); }} - onBack={() => wizard.setStep((s) => s - 1)} - onContinue={() => wizard.setStep((s) => s + 1)} + onBack={() => { + wizard.setShowErrors(false); + wizard.setStep((s) => s - 1); + }} + onContinue={() => { + wizard.setShowErrors(false); + wizard.setStep((s) => s + 1); + }} + onAttemptContinue={() => wizard.setShowErrors(true)} onCreate={wizard.handleCreate} isCreating={wizard.isCreating} /> diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts index a3f6a348..c0d45525 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts @@ -13,6 +13,7 @@ export function useNewWoWizardDraft(open: boolean) { }); const [svcOpen, setSvcOpen] = useState(false); const [svcSearch, setSvcSearch] = useState(""); + const [showErrors, setShowErrors] = useState(false); useEffect(() => { if (open) { @@ -20,6 +21,7 @@ export function useNewWoWizardDraft(open: boolean) { setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); setSvcOpen(false); setSvcSearch(""); + setShowErrors(false); } }, [open]); @@ -45,5 +47,7 @@ export function useNewWoWizardDraft(open: boolean) { setSvcOpen, svcSearch, setSvcSearch, + showErrors, + setShowErrors, }; } diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts index 748fb3c5..8f6b1e66 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts @@ -37,6 +37,7 @@ export function useNewWoWizardState({ }: UseNewWoWizardStateArgs) { const mockFallback = isWoWizardMockEnabled(); const [step, setStep] = useState(1); + const [showErrors, setShowErrors] = useState(false); const [draft, setDraft] = useState({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete", @@ -81,6 +82,7 @@ export function useNewWoWizardState({ useEffect(() => { if (open) { setStep(1); + setShowErrors(false); setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); setPocAutoFilledFor(""); setDuplicateRow(null); @@ -119,6 +121,8 @@ export function useNewWoWizardState({ return { step, setStep, + showErrors, + setShowErrors, draft, duplicateRow, setDuplicateRow, diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx index 4240f19a..6f01be6a 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx @@ -3,8 +3,10 @@ import { Button } from "@/components/ui/button"; import { WIZARD_STEPS, canContinueWizardStep1, + canContinueWizardStep2, canCreateWorkOrderFromWizard, isWizardStepValid, + isWizardWoNumberValid, type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; @@ -15,9 +17,24 @@ type WizardFooterProps = { onBack: () => void; onContinue: () => void; onCreate: () => void; + onAttemptContinue?: () => void; isCreating: boolean; }; +function stepHint(step: number, draft: WorkOrderWizardDraft): string | null { + if (step === 1 && !canContinueWizardStep1(draft)) { + if (!isWizardWoNumberValid(draft.woNumber)) { + return "Enter an alphanumeric WO number, or leave blank for SH generation"; + } + return "Select severity to continue"; + } + if (step === 2 && !canContinueWizardStep2(draft)) { + if (!draft.locationId) return "Pick a site to continue"; + return "Enter POC name and phone to continue"; + } + return null; +} + export function WizardFooter({ step, draft, @@ -25,11 +42,12 @@ export function WizardFooter({ onBack, onContinue, onCreate, + onAttemptContinue, isCreating, }: WizardFooterProps) { const stepValid = isWizardStepValid(step, draft); const canCreate = canCreateWorkOrderFromWizard(draft); - const canContinue1 = canContinueWizardStep1(draft); + const hint = stepHint(step, draft); return (
- {step === 1 && !canContinue1 && ( + {Boolean(hint) && ( - Pick a type to continue + {hint} )}
{step < WIZARD_STEPS.length ? ( ) : (