mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
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 <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:
parent
4af826a1e0
commit
b4cb9d0288
42 changed files with 905 additions and 162 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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's ticket — this is not a
|
||||||
|
judgment call.
|
||||||
|
</Text>
|
||||||
|
<SeverityOptionsList value={value} onSelect={onSelect} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
75
src/domain/work-orders/types/work-order-severity.ts
Normal file
75
src/domain/work-orders/types/work-order-severity.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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: "",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: "",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
43
tmp/pr-descriptions/pr2-severity-wizard.md
Normal file
43
tmp/pr-descriptions/pr2-severity-wizard.md
Normal 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`
|
||||||
Loading…
Add table
Reference in a new issue