feat(work-orders): severity gates and wizard type/WO# parity (SH-192) (#106)
Some checks failed
CI / ci (push) Has been cancelled
CI / governance (push) Has been cancelled
CI / vendor-visual-regression (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>
Co-authored-by: Arthur Bassi <bassi-arthurr@users.noreply.github.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
This commit is contained in:
Arthur Bassi 2026-08-14 11:59:52 -03:00 • committed by GitHub
parent 4af826a1e0
commit b4cb9d0288
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 905 additions and 162 deletions

View file

@ -21,6 +21,7 @@ export function buildInlineDraftCreateInput(
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }), siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
woNumber: draft.woNumber, woNumber: draft.woNumber,
type: draft.type || "PM", type: draft.type || "PM",
severity: draft.severity,
assigneeId: draft.dispatcherId, assigneeId: draft.dispatcherId,
assigneeName: assignee?.name ?? "", assigneeName: assignee?.name ?? "",
scheduledOn: draft.scheduledOn, scheduledOn: draft.scheduledOn,

View file

@ -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 (
<Tooltip title={title} enterDelay={400}>
<span
onClick={onClick}
role={onClick ? "button" : undefined}
className="inline-flex shrink-0 items-center whitespace-nowrap rounded leading-none"
style={{
fontSize: 10,
fontWeight: 600,
color,
border: `1px solid ${color}`,
padding: "1px 5px",
marginLeft: 4,
cursor: onClick ? "pointer" : "default",
}}
>
{lvl.label}
</span>
</Tooltip>
);
}

View file

@ -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 (
<div className="grid gap-1.5">
{WORK_ORDER_SEVERITIES.map((n) => {
const lvl = SEVERITY_LEVELS[n];
const color = SEVERITY_TOKEN_COLOR[lvl.token];
const selected = value === n;
return (
<button
key={n}
type="button"
onClick={() => onSelect(n)}
className="flex items-start gap-2 rounded-md text-left transition-colors"
style={{
padding: "6px 10px",
border: `1px solid ${selected ? color : "var(--color-border)"}`,
background: selected ? "var(--color-bg-muted)" : "transparent",
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
color,
border: `1px solid ${color}`,
borderRadius: 4,
padding: "1px 5px",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{lvl.label}
</span>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 12, color: "var(--color-text-primary)" }}>
{lvl.description}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{lvl.deadline}</span>
</div>
</button>
);
})}
</div>
);
}
type SeverityFieldProps = {
value: WorkOrderSeverity | null;
onSelect: (severity: WorkOrderSeverity) => void;
};
export function SeverityField({ value, onSelect }: SeverityFieldProps) {
return (
<div>
<Text
as="p"
variant="description"
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
>
Severity (from APM)
</Text>
<Text
as="p"
variant="description"
sx={{ fontSize: 11, color: "var(--color-text-muted)", margin: "0 0 8px" }}
>
Transcribe the severity already assigned on the client&apos;s ticket — this is not a
judgment call.
</Text>
<SeverityOptionsList value={value} onSelect={onSelect} />
</div>
);
}

View file

@ -1,25 +1,49 @@
import { useState } from "react"; import { useState } from "react";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { Popover } from "@mui/material"; 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 { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { HoverChevron } from "./hover-chevron"; import { HoverChevron } from "./hover-chevron";
import { SeverityBadge } from "./severity-badge";
import { TypeSeverityModal } from "./type-severity-modal";
import { WoTableTypeLabel } from "./wo-table-type-label"; import { WoTableTypeLabel } from "./wo-table-type-label";
type TypeCellProps = { type TypeCellProps = {
value: WOType | ""; value: WOType | "";
severity?: WorkOrderSeverity | null;
q?: string; q?: string;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
onSave: (v: WOType) => void;
}; };
export function TypeCell({ value, q, onSave }: TypeCellProps) { export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [modalSeed, setModalSeed] = useState<WOType | "">(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 ( return (
<> <>
@ -27,7 +51,6 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setAnchorEl(e.currentTarget); setAnchorEl(e.currentTarget);
}} }}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80" 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) {
) : ( ) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span> <span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)} )}
{Boolean(showSeverityBadge && severity != null) && (
<SeverityBadge
severity={severity!}
onClick={(e) => {
e.stopPropagation();
setModalSeed(value);
setResetSeverity(false);
setModalOpen(true);
}}
/>
)}
<HoverChevron /> <HoverChevron />
</button> </button>
@ -53,19 +86,22 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
<button <button
key={t} key={t}
type="button" type="button"
onClick={() => { onClick={() => pick(t)}
onSave(t);
setAnchorEl(null);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]" className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
> >
<WoTableTypeLabel type={t} /> <WoTableTypeLabel type={t} />
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />} {value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
</button> </button>
))} ))}
</Popover> </Popover>
<TypeSeverityModal
open={modalOpen}
onOpenChange={setModalOpen}
type={modalSeed}
severity={resetSeverity ? null : severity}
onSave={onSave}
/>
</> </>
); );
} }

View file

@ -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<WOType | "">(type);
const [draftSeverity, setDraftSeverity] = useState<WorkOrderSeverity | null>(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 (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth="xs"
fullWidth
onClick={(e) => e.stopPropagation()}
slotProps={{
paper: {
sx: { borderRadius: 2, background: "var(--color-bg-surface)" },
},
}}
>
<DialogTitle
sx={{
fontFamily: "var(--font-display)",
fontSize: 18,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
Type &amp; severity
</DialogTitle>
<DialogContent>
<div className="space-y-3">
<div>
<Text
as="p"
variant="description"
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
>
Type
</Text>
<div className="flex flex-wrap gap-1.5">
{WO_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => pickType(t)}
className="rounded-full transition-opacity hover:opacity-80"
style={{
padding: "4px 10px",
border: `1px solid ${
draftType === t ? "var(--color-primary)" : "var(--color-border)"
}`,
background: draftType === t ? "var(--color-primary-tint)" : "transparent",
}}
>
<WoTableTypeLabel type={t} />
</button>
))}
</div>
</div>
{Boolean(needsSeverity) && (
<SeverityField value={draftSeverity} onSelect={setDraftSeverity} />
)}
</div>
</DialogContent>
<DialogActions sx={{ padding: "12px 20px", gap: 1 }}>
<Button variant="text" size="small" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="contained"
size="small"
onClick={confirm}
disabled={!canConfirm}
sx={
canConfirm
? undefined
: {
opacity: 0.5,
cursor: "not-allowed",
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
}
}
>
Save
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -37,6 +37,24 @@ type WoTableInlineRowCellsProps = {
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
function InlineTypeSeverityCell({
draft,
onPatch,
}: {
draft: WorkOrderTableDraft;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
}) {
return (
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell
value={draft.type}
severity={draft.severity}
onSave={(t, sev) => onPatch({ type: t, severity: sev })}
/>
</td>
);
}
function InlineMutedDashCell() { function InlineMutedDashCell() {
return ( return (
<td style={{ padding: "0 14px" }}> <td style={{ padding: "0 14px" }}>
@ -81,9 +99,7 @@ export function WoTableInlineRowCells({
onDuplicateFound={onDuplicateFound} onDuplicateFound={onDuplicateFound}
/> />
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
<AssignedToCell <AssignedToCell

View file

@ -83,7 +83,12 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }} style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<TypeCell value={row.type} q={search} onSave={(t) => onPatchRow({ type: t })} /> <TypeCell
value={row.type}
severity={row.severity}
q={search}
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
/>
</td> </td>
<td <td
className="group/cell" className="group/cell"

View file

@ -68,6 +68,7 @@ export function NewWoWizard({
<WizardStepTypeSchedule <WizardStepTypeSchedule
draft={wizard.draft} draft={wizard.draft}
users={users} users={users}
showErrors={wizard.showErrors}
onUpdate={wizard.setField} onUpdate={wizard.setField}
onPatch={wizard.patchDraft} onPatch={wizard.patchDraft}
onDuplicateWoNumber={wizard.handleDuplicateFound} onDuplicateWoNumber={wizard.handleDuplicateFound}
@ -82,6 +83,7 @@ export function NewWoWizard({
locationDetailLoading={wizard.locationDetailLoading} locationDetailLoading={wizard.locationDetailLoading}
locationDetailError={wizard.locationDetailError} locationDetailError={wizard.locationDetailError}
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))} services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
showErrors={wizard.showErrors}
svcOpen={wizard.svcOpen} svcOpen={wizard.svcOpen}
onSvcOpenChange={wizard.setSvcOpen} onSvcOpenChange={wizard.setSvcOpen}
svcSearch={wizard.svcSearch} svcSearch={wizard.svcSearch}
@ -108,8 +110,15 @@ export function NewWoWizard({
onCancel={() => { onCancel={() => {
if (!wizard.isCreating) onOpenChange(false); if (!wizard.isCreating) onOpenChange(false);
}} }}
onBack={() => wizard.setStep((s) => s - 1)} onBack={() => {
onContinue={() => wizard.setStep((s) => s + 1)} wizard.setShowErrors(false);
wizard.setStep((s) => s - 1);
}}
onContinue={() => {
wizard.setShowErrors(false);
wizard.setStep((s) => s + 1);
}}
onAttemptContinue={() => wizard.setShowErrors(true)}
onCreate={wizard.handleCreate} onCreate={wizard.handleCreate}
isCreating={wizard.isCreating} isCreating={wizard.isCreating}
/> />

View file

@ -13,6 +13,7 @@ export function useNewWoWizardDraft(open: boolean) {
}); });
const [svcOpen, setSvcOpen] = useState(false); const [svcOpen, setSvcOpen] = useState(false);
const [svcSearch, setSvcSearch] = useState(""); const [svcSearch, setSvcSearch] = useState("");
const [showErrors, setShowErrors] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -20,6 +21,7 @@ export function useNewWoWizardDraft(open: boolean) {
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setSvcOpen(false); setSvcOpen(false);
setSvcSearch(""); setSvcSearch("");
setShowErrors(false);
} }
}, [open]); }, [open]);
@ -45,5 +47,7 @@ export function useNewWoWizardDraft(open: boolean) {
setSvcOpen, setSvcOpen,
svcSearch, svcSearch,
setSvcSearch, setSvcSearch,
showErrors,
setShowErrors,
}; };
} }

View file

@ -37,6 +37,7 @@ export function useNewWoWizardState({
}: UseNewWoWizardStateArgs) { }: UseNewWoWizardStateArgs) {
const mockFallback = isWoWizardMockEnabled(); const mockFallback = isWoWizardMockEnabled();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [showErrors, setShowErrors] = useState(false);
const [draft, setDraft] = useState<WorkOrderWizardDraft>({ const [draft, setDraft] = useState<WorkOrderWizardDraft>({
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
status: "Incomplete", status: "Incomplete",
@ -81,6 +82,7 @@ export function useNewWoWizardState({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setStep(1); setStep(1);
setShowErrors(false);
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setPocAutoFilledFor(""); setPocAutoFilledFor("");
setDuplicateRow(null); setDuplicateRow(null);
@ -119,6 +121,8 @@ export function useNewWoWizardState({
return { return {
step, step,
setStep, setStep,
showErrors,
setShowErrors,
draft, draft,
duplicateRow, duplicateRow,
setDuplicateRow, setDuplicateRow,

View file

@ -3,8 +3,10 @@ import { Button } from "@/components/ui/button";
import { import {
WIZARD_STEPS, WIZARD_STEPS,
canContinueWizardStep1, canContinueWizardStep1,
canContinueWizardStep2,
canCreateWorkOrderFromWizard, canCreateWorkOrderFromWizard,
isWizardStepValid, isWizardStepValid,
isWizardWoNumberValid,
type WorkOrderWizardDraft, type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
@ -15,9 +17,24 @@ type WizardFooterProps = {
onBack: () => void; onBack: () => void;
onContinue: () => void; onContinue: () => void;
onCreate: () => void; onCreate: () => void;
onAttemptContinue?: () => void;
isCreating: boolean; 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({ export function WizardFooter({
step, step,
draft, draft,
@ -25,11 +42,12 @@ export function WizardFooter({
onBack, onBack,
onContinue, onContinue,
onCreate, onCreate,
onAttemptContinue,
isCreating, isCreating,
}: WizardFooterProps) { }: WizardFooterProps) {
const stepValid = isWizardStepValid(step, draft); const stepValid = isWizardStepValid(step, draft);
const canCreate = canCreateWorkOrderFromWizard(draft); const canCreate = canCreateWorkOrderFromWizard(draft);
const canContinue1 = canContinueWizardStep1(draft); const hint = stepHint(step, draft);
return ( return (
<div <div
@ -60,20 +78,25 @@ export function WizardFooter({
)} )}
<div className="flex flex-1 justify-center"> <div className="flex flex-1 justify-center">
{step === 1 && !canContinue1 && ( {Boolean(hint) && (
<span <span
className="inline-flex items-center gap-1.5" className="inline-flex items-center gap-1.5"
style={{ fontSize: 12, color: "var(--muted-foreground)" }} style={{ fontSize: 12, color: "var(--muted-foreground)" }}
> >
<Lightbulb size={13} /> Pick a type to continue <Lightbulb size={13} /> {hint}
</span> </span>
)} )}
</div> </div>
{step < WIZARD_STEPS.length ? ( {step < WIZARD_STEPS.length ? (
<Button <Button
disabled={!stepValid} onClick={() => {
onClick={onContinue} if (!stepValid) {
onAttemptContinue?.();
return;
}
onContinue();
}}
endIcon={<ChevronRight size={14} />} endIcon={<ChevronRight size={14} />}
sx={{ sx={{
background: "var(--color-primary)", background: "var(--color-primary)",
@ -81,17 +104,23 @@ export function WizardFooter({
height: 36, height: 36,
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 500,
...(!stepValid ? { opacity: 0.5, pointerEvents: "none" } : {}), ...(!stepValid ? { opacity: 0.7 } : {}),
}} }}
> >
Continue Continue
</Button> </Button>
) : ( ) : (
<Button <Button
disabled={!canCreate || isCreating} disabled={isCreating}
loading={isCreating} loading={isCreating}
loadingText="Creating…" loadingText="Creating…"
onClick={onCreate} onClick={() => {
if (!canCreate) {
onAttemptContinue?.();
return;
}
onCreate();
}}
startIcon={!isCreating ? <Check size={14} /> : undefined} startIcon={!isCreating ? <Check size={14} /> : undefined}
sx={{ sx={{
background: "var(--color-primary)", background: "var(--color-primary)",
@ -99,7 +128,7 @@ export function WizardFooter({
height: 36, height: 36,
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 500,
...(!canCreate ? { opacity: 0.5, pointerEvents: "none" } : {}), ...(!canCreate ? { opacity: 0.7 } : {}),
}} }}
> >
Create work order Create work order

View file

@ -0,0 +1,31 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
import { SeverityOptionsList } from "@/app/(protected)/workorders/_components/list/table/cells/severity-options-list";
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardSeverityPickerProps = {
value: WorkOrderSeverity | null;
onSelect: (severity: WorkOrderSeverity) => void;
showError: boolean;
};
export function WizardSeverityPicker({ value, onSelect, showError }: WizardSeverityPickerProps) {
return (
<div>
<WizLabel required>Severity (from APM)</WizLabel>
<Text
as="p"
variant="description"
sx={{ fontSize: 11, color: "var(--muted-foreground)", margin: "0 0 8px" }}
>
Transcribe the severity already assigned on the client&apos;s ticket.
</Text>
<SeverityOptionsList value={value} onSelect={onSelect} />
{Boolean(showError && value == null) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
Severity is required for Emergency and Reactive work orders.
</Text>
)}
</div>
);
}

View file

@ -4,17 +4,24 @@ import {
DLG_INPUT_CLS, DLG_INPUT_CLS,
DLG_INPUT_STYLE, DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; } from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardStepLocationPocFieldsProps = { type WizardStepLocationPocFieldsProps = {
draft: WorkOrderWizardDraft; draft: WorkOrderWizardDraft;
showErrors?: boolean;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void; onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
}; };
export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocationPocFieldsProps) { export function WizardStepLocationPocFields({
draft,
showErrors = false,
onUpdate,
}: WizardStepLocationPocFieldsProps) {
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
return ( return (
<div> <div>
<WizSubLabel>Point of contact</WizSubLabel> <WizLabel required>Point of contact</WizLabel>
<Text <Text
as="p" as="p"
variant="description" variant="description"
@ -38,6 +45,11 @@ export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocat
style={DLG_INPUT_STYLE} style={DLG_INPUT_STYLE}
/> />
</div> </div>
{Boolean(showErrors && pocIncomplete) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
POC name and phone are required.
</Text>
)}
<textarea <textarea
value={draft.pocNotes} value={draft.pocNotes}
onChange={(e) => onUpdate("pocNotes", e.target.value)} onChange={(e) => onUpdate("pocNotes", e.target.value)}

View file

@ -16,6 +16,7 @@ type WizardStepLocationServiceProps = {
locationDetailLoading?: boolean; locationDetailLoading?: boolean;
locationDetailError?: boolean; locationDetailError?: boolean;
services: ServiceOption[]; services: ServiceOption[];
showErrors?: boolean;
svcOpen: boolean; svcOpen: boolean;
onSvcOpenChange: (open: boolean) => void; onSvcOpenChange: (open: boolean) => void;
svcSearch: string; svcSearch: string;
@ -31,6 +32,7 @@ export function WizardStepLocationService({
locationDetailLoading, locationDetailLoading,
locationDetailError, locationDetailError,
services, services,
showErrors = false,
svcOpen, svcOpen,
onSvcOpenChange, onSvcOpenChange,
svcSearch, svcSearch,
@ -57,7 +59,7 @@ export function WizardStepLocationService({
locationDetailLoading={locationDetailLoading} locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError} locationDetailError={locationDetailError}
/> />
<WizardStepLocationPocFields draft={draft} onUpdate={onUpdate} /> <WizardStepLocationPocFields draft={draft} showErrors={showErrors} onUpdate={onUpdate} />
</> </>
)} )}

View file

@ -1,7 +1,16 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; import {
WO_TYPE_META,
WO_TYPES,
isWizardWoNumberValid,
type WOType,
} from "@/domain/work-orders/types/work-order-wizard";
import {
typeNeedsSeverity,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field"; import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field"; import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
@ -15,6 +24,7 @@ import {
WizLabel, WizLabel,
WizLabelRow, WizLabelRow,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker";
import { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react"; import { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react";
import type { ElementType } from "react"; import type { ElementType } from "react";
@ -26,6 +36,17 @@ const WO_TYPE_ICONS: Record<WOType, ElementType> = {
Overdue: Clock, Overdue: Clock,
}; };
function nextSeverityForType(
currentType: WOType | "",
nextType: WOType,
currentSeverity: WorkOrderSeverity | null,
): WorkOrderSeverity | null {
if (!typeNeedsSeverity(nextType) || currentType !== nextType) {
return null;
}
return currentSeverity;
}
function WoTypePicker({ function WoTypePicker({
selected, selected,
onSelect, onSelect,
@ -113,9 +134,92 @@ function WoTypePicker({
); );
} }
type WizardTypeScheduleDatesProps = {
draft: WorkOrderWizardDraft;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeScheduleDatesProps) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
? () =>
onPatch({
scheduledOn: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
targetWeek: "",
})
: undefined
}
>
Schedule On
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
})
}
onChangeWeek={(w) =>
onPatch({
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
scheduleEnd: "",
schedMode: "week",
targetWeek: w,
})
}
onChangeRange={(s, e) =>
onPatch({
scheduledOn: s,
scheduleEnd: e,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
>
Due Date
</WizLabelRow>
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}
/>
</div>
</div>
);
}
type WizardStepTypeScheduleProps = { type WizardStepTypeScheduleProps = {
draft: WorkOrderWizardDraft; draft: WorkOrderWizardDraft;
users: WorkOrderUser[]; users: WorkOrderUser[];
showErrors?: boolean;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void; onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void; onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
onDuplicateWoNumber?: (woNumber: string) => void; onDuplicateWoNumber?: (woNumber: string) => void;
@ -124,32 +228,57 @@ type WizardStepTypeScheduleProps = {
export function WizardStepTypeSchedule({ export function WizardStepTypeSchedule({
draft, draft,
users, users,
showErrors = false,
onUpdate, onUpdate,
onPatch, onPatch,
onDuplicateWoNumber, onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) { }: WizardStepTypeScheduleProps) {
const needsSeverity = typeNeedsSeverity(draft.type);
const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber);
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId); const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<WizLabel required>Type of WO</WizLabel> <WizLabel optional>Type of WO</WizLabel>
<WoTypePicker selected={draft.type} onSelect={(t) => onUpdate("type", t)} /> <WoTypePicker
selected={draft.type}
onSelect={(t) => {
onPatch({
type: t,
severity: nextSeverityForType(draft.type, t, draft.severity),
});
}}
/>
</div> </div>
{Boolean(needsSeverity) && (
<WizardSeverityPicker
value={draft.severity}
showError={showErrors}
onSelect={(severity) => onUpdate("severity", severity)}
/>
)}
<div> <div>
<WizLabel optional>WO Number</WizLabel> <WizLabel optional>WO Number</WizLabel>
<input <input
type="text" type="text"
inputMode="numeric"
value={draft.woNumber} value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))} onChange={(e) =>
onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20))
}
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)} onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
placeholder="e.g. 12345678901" placeholder="Leave blank for SH provisional number"
maxLength={20} maxLength={20}
className={DLG_INPUT_CLS} className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE} style={DLG_INPUT_STYLE}
/> />
{Boolean(woNumberError) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
WO number must be alphanumeric.
</Text>
)}
</div> </div>
<div> <div>
@ -180,77 +309,7 @@ export function WizardStepTypeSchedule({
/> />
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}> <WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
<div>
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
? () =>
onPatch({
scheduledOn: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
targetWeek: "",
})
: undefined
}
>
Schedule On
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
})
}
onChangeWeek={(w) =>
onPatch({
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
scheduleEnd: "",
schedMode: "week",
targetWeek: w,
})
}
onChangeRange={(s, e) =>
onPatch({
scheduledOn: s,
scheduleEnd: e,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
>
Due Date
</WizLabelRow>
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}
/>
</div>
</div>
</div> </div>
); );
} }

View file

@ -53,4 +53,8 @@ export function assignWizardServicePocFields(
if (vendorNotes) { if (vendorNotes) {
payload.vendorNotes = vendorNotes; payload.vendorNotes = vendorNotes;
} }
if (draft.severity != null) {
payload.severity = draft.severity;
}
} }

View file

@ -18,6 +18,12 @@ type ScalarBoardOpRule = {
/** One row per scalar patch field, in emission order. */ /** One row per scalar patch field, in emission order. */
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "woNumber", field: "woNumber", toValue: (value) => value as string }, { key: "woNumber", field: "woNumber", toValue: (value) => value as string },
// Severity before type so Emergency/Reactive never land without required severity.
{
key: "severity",
field: "severity",
toValue: (value) => (value == null ? "" : String(value)),
},
{ key: "type", field: "workOrderType", toValue: (value) => value as string }, { key: "type", field: "workOrderType", toValue: (value) => value as string },
{ key: "site", field: "siteCode", toValue: (value) => value as string }, { key: "site", field: "siteCode", toValue: (value) => value as string },
{ key: "status", field: "lifecycleStatus", toValue: (value) => value as string }, { key: "status", field: "lifecycleStatus", toValue: (value) => value as string },

View file

@ -1,8 +1,9 @@
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail"; import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
import type { import {
WOType, effectiveWizardType,
WizardWOStatus, type WOType,
WorkOrderWizardDraft, type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common"; import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
@ -66,13 +67,14 @@ export function mapTypeToPriority(type: WOType | ""): string {
} }
function buildTitle(draft: WorkOrderWizardDraft): string { function buildTitle(draft: WorkOrderWizardDraft): string {
const typeLabel = effectiveWizardType(draft.type);
if (draft.woNumber) { if (draft.woNumber) {
return `${draft.type || "Work Order"} — #${draft.woNumber}`; return `${typeLabel} — #${draft.woNumber}`;
} }
if (draft.locationName) { if (draft.locationName) {
return `${draft.type || "Work Order"} — ${draft.locationName}`; return `${typeLabel} — ${draft.locationName}`;
} }
return draft.type || "Work Order"; return typeLabel;
} }
function buildDescription(draft: WorkOrderWizardDraft): string { function buildDescription(draft: WorkOrderWizardDraft): string {
@ -103,7 +105,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
workOrderNumber: draft.woNumber, workOrderNumber: draft.woNumber,
title: buildTitle(draft), title: buildTitle(draft),
locationId: draft.locationId, locationId: draft.locationId,
priority: mapTypeToPriority(draft.type), priority: mapTypeToPriority(effectiveWizardType(draft.type)),
status: mapWizardStatusToApi(draft.status), status: mapWizardStatusToApi(draft.status),
createdDate: todayIso(), createdDate: todayIso(),
dueDate: draft.dueDate, dueDate: draft.dueDate,
@ -118,6 +120,6 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
trade: "", trade: "",
subTrade: "", subTrade: "",
vendorNTE: "", vendorNTE: "",
source: draft.type, source: effectiveWizardType(draft.type),
}; };
} }

View file

@ -87,7 +87,7 @@ export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
export function mapWizardDraftToBoardCreatePayload( export function mapWizardDraftToBoardCreatePayload(
draft: WorkOrderWizardDraft, draft: WorkOrderWizardDraft,
): WorkOrderBoardCreatePayload { ): WorkOrderBoardCreatePayload {
const workOrderType = mapWizardTypeToApi(draft.type); const workOrderType = mapWizardTypeToApi(draft.type || "PM");
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName }); const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });

View file

@ -8,6 +8,7 @@ import type {
CompletionDocStatus, CompletionDocStatus,
WorkOrderTableRow, WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row"; } from "@/domain/work-orders/types/work-order-table-row";
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils"; import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
@ -234,6 +235,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
woNumber, woNumber,
site: site || location.slice(0, 8), site: site || location.slice(0, 8),
type: readWoType(item), type: readWoType(item),
severity: parseWorkOrderSeverity(item.severity ?? item.Severity),
dispatcherId, dispatcherId,
dispatcherName, dispatcherName,
scheduledOn, scheduledOn,

View file

@ -93,6 +93,8 @@ export interface WorkOrderBoardCreatePayload {
pocNotes?: string; pocNotes?: string;
techPhone?: string; techPhone?: string;
vendorNotes?: string; vendorNotes?: string;
/** Severity 1–5 for Emergency/Reactive. */
severity?: number;
} }
/** Payload for PATCH /workorders/{id}/board (one field per request). */ /** Payload for PATCH /workorders/{id}/board (one field per request). */
@ -122,7 +124,8 @@ export type WorkOrderBoardEditableField =
| "pm" | "pm"
| "serviceNotes" | "serviceNotes"
| "extraServices" | "extraServices"
| "flagColor"; | "flagColor"
| "severity";
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */ /** Espelha WorkOrderBoardRowDto (JSON camelCase). */
export interface WorkOrderBoardRowApi { export interface WorkOrderBoardRowApi {
@ -147,6 +150,8 @@ export interface WorkOrderBoardRowApi {
color?: string | null; color?: string | null;
/** Board flag color (#RRGGBB) or null when cleared. */ /** Board flag color (#RRGGBB) or null when cleared. */
flagColor?: string | null; flagColor?: string | null;
/** Severity 1–5 for Emergency/Reactive. */
severity?: number | null;
dueDate?: string | null; dueDate?: string | null;
scheduledDate?: string | null; scheduledDate?: string | null;
/** ISO date when work was completed; optional on list rows until BE confirms. */ /** ISO date when work was completed; optional on list rows until BE confirms. */

View file

@ -0,0 +1,75 @@
export type WorkOrderSeverity = 1 | 2 | 3 | 4 | 5;
export const WORK_ORDER_SEVERITIES: readonly WorkOrderSeverity[] = [1, 2, 3, 4, 5];
/** Types that require an APM severity transcription. */
export const SEVERITY_TYPES = ["Emergency", "Reactive"] as const;
export type SeverityWoType = (typeof SEVERITY_TYPES)[number];
export type SeverityToken = "critical" | "warning" | "safe";
export const SEVERITY_LEVELS: Record<
WorkOrderSeverity,
{
label: string;
description: string;
deadline: string;
afterHours: string;
token: SeverityToken;
}
> = {
1: {
label: "SEV 1",
description: "Business Critical Function Down",
deadline: "Accept: 15 min · Respond: 2 hrs",
afterHours: "Yes",
token: "critical",
},
2: {
label: "SEV 2",
description: "Business Critical Function Impaired",
deadline: "Accept: 30 min · Respond: 4 hrs",
afterHours: "Yes",
token: "critical",
},
3: {
label: "SEV 3",
description: "Group Productivity Impaired",
deadline: "Accept: 2 hrs · Respond: 8 hrs (next day if after hours)",
afterHours: "Approval required",
token: "warning",
},
4: {
label: "SEV 4",
description: "Individual Productivity Impaired",
deadline: "Accept: 12 hrs · Respond: 24 hrs (next day)",
afterHours: "No",
token: "safe",
},
5: {
label: "SEV 5",
description: "Productivity not immediately affected",
deadline: "Accept: 12 hrs · Respond: 72 hrs",
afterHours: "No",
token: "safe",
},
};
export const SEVERITY_TOKEN_COLOR: Record<SeverityToken, string> = {
critical: "var(--sla-critical, #DC2626)",
warning: "var(--sla-warning, #D97706)",
safe: "var(--sla-safe, #16A34A)",
};
export function typeNeedsSeverity(type: string): boolean {
return (SEVERITY_TYPES as readonly string[]).includes(type);
}
export function parseWorkOrderSeverity(value: unknown): WorkOrderSeverity | null {
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
return n;
}
return null;
}

View file

@ -1,4 +1,5 @@
import type { WizardWOStatus, WOType } from "./work-order-wizard"; import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
export type CompletionDocStatus = "Yes" | "No" | "NN" | ""; export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
@ -9,6 +10,8 @@ export interface WorkOrderTableRow {
woNumber: string; woNumber: string;
site: string; site: string;
type: WOType | ""; type: WOType | "";
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
severity: WorkOrderSeverity | null;
dispatcherId: string; dispatcherId: string;
dispatcherName: string; dispatcherName: string;
scheduledOn: string; scheduledOn: string;
@ -57,6 +60,7 @@ export interface WorkOrderTableDraft {
site: string; site: string;
woNumber: string; woNumber: string;
type: WOType | ""; type: WOType | "";
severity: WorkOrderSeverity | null;
dispatcherId: string; dispatcherId: string;
scheduledOn: string; scheduledOn: string;
schedNotes: string; schedNotes: string;
@ -84,6 +88,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
site: "", site: "",
woNumber: "", woNumber: "",
type: "", type: "",
severity: null,
dispatcherId: "", dispatcherId: "",
scheduledOn: "", scheduledOn: "",
schedNotes: "", schedNotes: "",

View file

@ -1,3 +1,7 @@
import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity";
export type { WorkOrderSeverity } from "./work-order-severity";
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const; export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
export type WOType = (typeof WO_TYPES)[number]; export type WOType = (typeof WO_TYPES)[number];
@ -24,6 +28,8 @@ export interface WorkOrderWizardDraft {
siteCode: string; siteCode: string;
woNumber: string; woNumber: string;
type: WOType | ""; type: WOType | "";
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
severity: WorkOrderSeverity | null;
assigneeId: string; assigneeId: string;
assigneeName: string; assigneeName: string;
scheduledOn: string; scheduledOn: string;
@ -58,6 +64,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
siteCode: "", siteCode: "",
woNumber: "", woNumber: "",
type: "", type: "",
severity: null,
assigneeId: "", assigneeId: "",
assigneeName: "", assigneeName: "",
scheduledOn: "", scheduledOn: "",
@ -81,13 +88,22 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
schedMode: undefined, schedMode: undefined,
}; };
/** WO number is optional; when provided must be digits only, 1–20 characters. */ /** Blank numbers receive an SH provisional number from the backend; nonblank values are alphanumeric. */
export function isWizardWoNumberValid(woNumber: string): boolean { export function isWizardWoNumberValid(woNumber: string): boolean {
const trimmed = woNumber.trim(); const trimmed = woNumber.trim();
if (!trimmed) { if (!trimmed) {
return true; return true;
} }
return /^\d{1,20}$/.test(trimmed); return trimmed.length <= 20 && /^[a-z0-9]+$/i.test(trimmed);
}
/** Type is optional at creation; empty draft type defaults to PM. */
export function effectiveWizardType(type: WOType | ""): WOType {
return type || "PM";
}
export function wizardNeedsSeverity(draft: WorkOrderWizardDraft): boolean {
return typeNeedsSeverity(draft.type);
} }
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [ export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
@ -159,11 +175,22 @@ export const TIME_SLOTS: string[] = (() => {
})(); })();
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean { export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
return Boolean(draft.type) && isWizardWoNumberValid(draft.woNumber); if (!isWizardWoNumberValid(draft.woNumber)) {
return false;
}
if (wizardNeedsSeverity(draft) && draft.severity == null) {
return false;
}
return true;
}
export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean {
const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim()));
return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim());
} }
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean { export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
return Boolean(draft.type && draft.siteCode) && isWizardWoNumberValid(draft.woNumber); return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
} }
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean { export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
@ -171,7 +198,7 @@ export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): bo
return canContinueWizardStep1(draft); return canContinueWizardStep1(draft);
} }
if (step === 2) { if (step === 2) {
return Boolean(draft.locationId && draft.siteCode); return canContinueWizardStep2(draft);
} }
return true; return true;
} }

View file

@ -28,11 +28,12 @@ vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast"
const SCHEDULED_ON = "2026-06-01"; const SCHEDULED_ON = "2026-06-01";
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow { function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return { const defaults: WorkOrderTableRow = {
id: 42, id: 42,
woNumber: "WO-42", woNumber: "WO-42",
site: "SEA1", site: "SEA1",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: SCHEDULED_ON, scheduledOn: SCHEDULED_ON,
@ -71,7 +72,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "", rowVersion: "",
dispatchRowVersion: "", dispatchRowVersion: "",
flagColor: null, flagColor: null,
};
return {
...defaults,
...overrides, ...overrides,
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
}; };
} }

View file

@ -45,6 +45,7 @@ function baseRow(): WorkOrderTableRow {
woNumber: "WO-7", woNumber: "WO-7",
site: "SEA1", site: "SEA1",
type: "Emergency", type: "Emergency",
severity: 1,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01", scheduledOn: "2026-06-01",

View file

@ -48,7 +48,7 @@ describe("NewWoWizard duplicate wo number", () => {
/>, />,
); );
const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901"); const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number");
fireEvent.change(woNumberInput, { fireEvent.change(woNumberInput, {
target: { value: "12345678901" }, target: { value: "12345678901" },
}); });
@ -77,7 +77,7 @@ describe("NewWoWizard duplicate wo number", () => {
/>, />,
); );
const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901"); const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number");
fireEvent.change(woNumberInput, { fireEvent.change(woNumberInput, {
target: { value: "98765432109" }, target: { value: "98765432109" },
}); });

View file

@ -15,11 +15,12 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
})); }));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow { function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return { const defaults: WorkOrderTableRow = {
id: 42, id: 42,
woNumber: "WO-42", woNumber: "WO-42",
site: "SEA1", site: "SEA1",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01", scheduledOn: "2026-06-01",
@ -58,7 +59,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "", rowVersion: "",
dispatchRowVersion: "", dispatchRowVersion: "",
flagColor: null, flagColor: null,
};
return {
...defaults,
...overrides, ...overrides,
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
}; };
} }

View file

@ -10,6 +10,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "100", woNumber: "100",
site: "SITE", site: "SITE",
type: "Emergency", type: "Emergency",
severity: null,
dispatcherId: "d1", dispatcherId: "d1",
dispatcherName: "Alex", dispatcherName: "Alex",
scheduledOn: "2026-07-16", scheduledOn: "2026-07-16",

View file

@ -14,6 +14,7 @@ const unscheduledRow: WorkOrderTableRow = {
woNumber: "WO-42", woNumber: "WO-42",
site: "CHI-1042", site: "CHI-1042",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Test Dispatcher", dispatcherName: "Test Dispatcher",
scheduledOn: "", scheduledOn: "",

View file

@ -53,6 +53,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "WO-42", woNumber: "WO-42",
site: "SEA1", site: "SEA1",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20", scheduledOn: "2026-07-20",

View file

@ -72,6 +72,7 @@ const canceledRow: WorkOrderTableRow = {
woNumber: "WO-77", woNumber: "WO-77",
site: "CHI-1042", site: "CHI-1042",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Test Dispatcher", dispatcherName: "Test Dispatcher",
scheduledOn: "2026-07-20", scheduledOn: "2026-07-20",
@ -216,9 +217,15 @@ describe("WorkOrdersListPage board-core public interface", () => {
expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
expect(screen.getByText(/pick a type to continue/i)).toBeInTheDocument(); expect(
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled(); screen.getByPlaceholderText(/leave blank for sh provisional number/i),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: /continue/i }));
expect(screen.queryByText(/wo number is required/i)).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: /location & service/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^back$/i }));
fireEvent.click(screen.getByRole("button", { name: /cancel/i })); fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
await waitForElementToBeRemoved(() => screen.queryByRole("dialog")); await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));

View file

@ -3,8 +3,10 @@ import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-
import { import {
EMPTY_WIZARD_DRAFT, EMPTY_WIZARD_DRAFT,
canContinueWizardStep1, canContinueWizardStep1,
canContinueWizardStep2,
canCreateWorkOrderFromWizard, canCreateWorkOrderFromWizard,
isWizardStepValid, isWizardStepValid,
isWizardWoNumberValid,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
describe("mapWizardDraftToFormValues", () => { describe("mapWizardDraftToFormValues", () => {
@ -63,12 +65,18 @@ describe("mapWizardDraftToFormValues", () => {
}); });
describe("wizard step validation", () => { describe("wizard step validation", () => {
it("blocks step 1 continue without type", () => { it("allows step 1 continue without a woNumber for backend SH generation", () => {
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(false); expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true);
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false); expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(true);
}); });
it("allows step 1 when type is set without woNumber", () => { it("allows step 1 without type when woNumber is set (type defaults to PM)", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, woNumber: "12345" };
expect(canContinueWizardStep1(draft)).toBe(true);
expect(isWizardStepValid(1, draft)).toBe(true);
});
it("allows step 1 when woNumber is missing even with type", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const }; const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
expect(canContinueWizardStep1(draft)).toBe(true); expect(canContinueWizardStep1(draft)).toBe(true);
expect(isWizardStepValid(1, draft)).toBe(true); expect(isWizardStepValid(1, draft)).toBe(true);
@ -84,6 +92,51 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(1, draft)).toBe(true); expect(isWizardStepValid(1, draft)).toBe(true);
}); });
it("blocks step 1 for Emergency without severity", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "Emergency" as const,
woNumber: "12345",
};
expect(canContinueWizardStep1(draft)).toBe(false);
});
it("allows step 1 for Emergency with severity", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "Emergency" as const,
woNumber: "12345",
severity: 2 as const,
};
expect(canContinueWizardStep1(draft)).toBe(true);
});
it("blocks step 2 without POC name/phone", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345",
locationId: "99",
siteCode: "BK5",
};
expect(canContinueWizardStep2(draft)).toBe(false);
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows step 2 when site and POC are set", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345",
locationId: "99",
siteCode: "BK5",
pocName: "Jordan",
pocPhone: "555-0100",
};
expect(canContinueWizardStep2(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true);
});
it("blocks create without siteCode", () => { it("blocks create without siteCode", () => {
const draft = { const draft = {
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
@ -95,44 +148,54 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(false); expect(isWizardStepValid(2, draft)).toBe(false);
}); });
it("allows create when type and siteCode are set (woNumber optional)", () => { it("allows create when type, woNumber, site, and POC are set", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
locationId: "99",
siteCode: "BK5",
};
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true);
});
it("allows create when type, woNumber, and siteCode are set", () => {
const draft = { const draft = {
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
type: "PM" as const, type: "PM" as const,
woNumber: "12345678901234567890", woNumber: "12345678901234567890",
locationId: "99", locationId: "99",
siteCode: "BK5", siteCode: "BK5",
pocName: "Jordan",
pocPhone: "555-0100",
}; };
expect(canCreateWorkOrderFromWizard(draft)).toBe(true); expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true); expect(isWizardStepValid(2, draft)).toBe(true);
}); });
it("allows step 1 when woNumber is empty", () => { it("allows provisional SH-prefixed woNumber", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
woNumber: "SH12345",
};
expect(canContinueWizardStep1(draft)).toBe(true);
});
it("accepts blank woNumber for backend SH generation", () => {
const draft = { const draft = {
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
type: "PM" as const, type: "PM" as const,
woNumber: "", woNumber: "",
}; };
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
expect(canContinueWizardStep1(draft)).toBe(true); expect(canContinueWizardStep1(draft)).toBe(true);
}); });
it("blocks step 1 when woNumber is non-numeric", () => { it("accepts whitespace-only woNumber for backend SH generation", () => {
const draft = { const draft = {
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
type: "PM" as const, type: "PM" as const,
woNumber: "abc", woNumber: " ",
}; };
expect(canContinueWizardStep1(draft)).toBe(false); expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
expect(canContinueWizardStep1(draft)).toBe(true);
});
it("rejects non-alphanumeric woNumber", () => {
expect(isWizardWoNumberValid("!!!")).toBe(false);
expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, woNumber: "!!!" })).toBe(false);
});
it("accepts alphanumeric woNumber", () => {
expect(isWizardWoNumberValid("ABC123")).toBe(true);
}); });
}); });

View file

@ -265,14 +265,12 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.workOrderType).toBe(3); expect(payload.workOrderType).toBe(3);
}); });
it("throws when type is missing", () => { it("defaults empty type to PM", () => {
expect(() => const payload = mapWizardDraftToBoardCreatePayload({
mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT,
...EMPTY_WIZARD_DRAFT, siteCode: "BK5",
});
siteCode: "BK5", expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.PM);
}),
).toThrow("Work order type is required");
}); });
it("throws when siteCode cannot be resolved", () => { it("throws when siteCode cannot be resolved", () => {

View file

@ -59,6 +59,12 @@ describe("expandTablePatchToBoardFieldOps", () => {
expect(expandTablePatchToBoardFieldOps({ flagColor: null })).toEqual([ expect(expandTablePatchToBoardFieldOps({ flagColor: null })).toEqual([
{ field: "flagColor", value: "" }, { field: "flagColor", value: "" },
]); ]);
expect(expandTablePatchToBoardFieldOps({ severity: 3 })).toEqual([
{ field: "severity", value: "3" },
]);
expect(expandTablePatchToBoardFieldOps({ severity: null })).toEqual([
{ field: "severity", value: "" },
]);
}); });
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => { it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {

View file

@ -147,11 +147,13 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
docStatus: 1, docStatus: 1,
dayGroup: "wednesday", dayGroup: "wednesday",
flagColor: "#EF4444", flagColor: "#EF4444",
severity: 2,
}); });
expect(row.woNumber).toBe("10000001235"); expect(row.woNumber).toBe("10000001235");
expect(row.site).toBe("BK5"); expect(row.site).toBe("BK5");
expect(row.type).toBe("Reactive"); expect(row.type).toBe("Reactive");
expect(row.severity).toBe(2);
expect(row.status).toBe("Scheduled"); expect(row.status).toBe("Scheduled");
expect(row.scheduledOn).toBe("2026-07-15"); expect(row.scheduledOn).toBe("2026-07-15");
expect(row.dueDate).toBe("2026-07-18"); expect(row.dueDate).toBe("2026-07-18");

View file

@ -17,6 +17,7 @@ const BASE_ROW = {
id: 99, id: 99,
rowVersion: "AAAAAAA=", rowVersion: "AAAAAAA=",
completedDate: "", completedDate: "",
severity: null,
} as WorkOrderTableRow; } as WorkOrderTableRow;
describe("runWorkOrderTablePatch completedDate", () => { describe("runWorkOrderTablePatch completedDate", () => {

View file

@ -50,11 +50,12 @@ const BASE_SERVER: ServerState = {
docStatus: "No", docStatus: "No",
}; };
const BASE_ROW = { const BASE_ROW: WorkOrderTableRow = {
id: 42, id: 42,
woNumber: "WO-42", woNumber: "WO-42",
site: "SEA1", site: "SEA1",
type: "PM", type: "PM",
severity: null,
dispatcherId: "d1", dispatcherId: "d1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01", scheduledOn: "2026-06-01",
@ -93,7 +94,7 @@ const BASE_ROW = {
rowVersion: "AAAAAAA=", rowVersion: "AAAAAAA=",
dispatchRowVersion: "", dispatchRowVersion: "",
flagColor: null, flagColor: null,
} as WorkOrderTableRow; };
describe("useWorkOrderTableMutations completion-doc persistence", () => { describe("useWorkOrderTableMutations completion-doc persistence", () => {
let serverState: ServerState; let serverState: ServerState;

View file

@ -12,6 +12,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "1001", woNumber: "1001",
site: "SITE-A", site: "SITE-A",
type: "PM", type: "PM",
severity: null,
dispatcherId: "10", dispatcherId: "10",
dispatcherName: "Alex", dispatcherName: "Alex",
scheduledOn: "2026-07-16", scheduledOn: "2026-07-16",

View file

@ -34,6 +34,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
woNumber: "1001", woNumber: "1001",
site: "SITE-A", site: "SITE-A",
type: "PM", type: "PM",
severity: null,
dispatcherId: "10", dispatcherId: "10",
dispatcherName: "Alex", dispatcherName: "Alex",
scheduledOn: "2026-07-16", scheduledOn: "2026-07-16",

View file

@ -23,6 +23,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "12345678901", woNumber: "12345678901",
site: "SITE01", site: "SITE01",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Alice", dispatcherName: "Alice",
scheduledOn: "2026-06-23", scheduledOn: "2026-06-23",

View file

@ -0,0 +1,43 @@
## Jira
- [SH-192](https://luby-us.atlassian.net/browse/SH-192) — severity on Emergency/Reactive
- Related: [SH-107](https://luby-us.atlassian.net/browse/SH-107) (WO# provisional SH…), [SH-108](https://luby-us.atlassian.net/browse/SH-108) (POC)
## QA residual
- P0 wizard fixed: Type optional (default PM); WO# alphanumeric / SH provisional (not digits-only).
- P1: COL_HELP tooltips, Scheduled-requires-date — follow-up.
## Summary
- Add SEV 1–5 domain field on board row/create/patch and Type cell modal for Emergency/Reactive.
- Wizard gates: required WO# (alphanumeric/SH ok), Type optional (defaults PM), severity when E/R, POC name/phone on step 2 with error UX.
## Scope
### In
- `severity` on WorkOrderBoardRowApi / create payload / editable field / table row / wizard draft
- Type cell popover + TypeSeverityModal + SeverityBadge
- Wizard step1/step2 validation + footer attempt-continue errors
- Unit tests for canContinue* and severity patch mapping
### Out (follow-up PRs)
- Advanced filter severity facet (PR5)
- Uplift UI, Completed Date column, multi-POC, SH auto-numbering beyond wizard gate
## API / BE
- Contract assumed: `severity` on GET board/search row, POST create, and PATCH field `severity`
- Blocked on BE: none for FE wiring; 422 if severity missing for E/R should be handled by BE
## Test plan
- [ ] Board: pick Emergency/Reactive → severity modal required; badge shows SEV
- [ ] Wizard: cannot continue step1 without WO#; E/R without severity; step2 without POC
- [ ] `npm run verify` green locally
## Base
- Base branch: `dev`