mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43: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 }),
|
||||
woNumber: draft.woNumber,
|
||||
type: draft.type || "PM",
|
||||
severity: draft.severity,
|
||||
assigneeId: draft.dispatcherId,
|
||||
assigneeName: assignee?.name ?? "",
|
||||
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 { Check } from "lucide-react";
|
||||
|
||||
import { Popover } from "@mui/material";
|
||||
|
||||
import {
|
||||
typeNeedsSeverity,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
||||
import { SeverityBadge } from "./severity-badge";
|
||||
import { TypeSeverityModal } from "./type-severity-modal";
|
||||
import { WoTableTypeLabel } from "./wo-table-type-label";
|
||||
|
||||
type TypeCellProps = {
|
||||
value: WOType | "";
|
||||
|
||||
severity?: WorkOrderSeverity | null;
|
||||
q?: string;
|
||||
|
||||
onSave: (v: WOType) => void;
|
||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||
};
|
||||
|
||||
export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
||||
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<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 (
|
||||
<>
|
||||
|
|
@ -27,7 +51,6 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
|||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||
|
|
@ -37,7 +60,17 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
|||
) : (
|
||||
<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 />
|
||||
</button>
|
||||
|
||||
|
|
@ -53,19 +86,22 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
|||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(t);
|
||||
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
onClick={() => pick(t)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<WoTableTypeLabel type={t} />
|
||||
|
||||
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
|
||||
</button>
|
||||
))}
|
||||
</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;
|
||||
};
|
||||
|
||||
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() {
|
||||
return (
|
||||
<td style={{ padding: "0 14px" }}>
|
||||
|
|
@ -81,9 +99,7 @@ export function WoTableInlineRowCells({
|
|||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
||||
</td>
|
||||
<InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<AssignedToCell
|
||||
|
|
|
|||
|
|
@ -83,7 +83,12 @@ export function WoTableRowCells({
|
|||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
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
|
||||
className="group/cell"
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export function NewWoWizard({
|
|||
<WizardStepTypeSchedule
|
||||
draft={wizard.draft}
|
||||
users={users}
|
||||
showErrors={wizard.showErrors}
|
||||
onUpdate={wizard.setField}
|
||||
onPatch={wizard.patchDraft}
|
||||
onDuplicateWoNumber={wizard.handleDuplicateFound}
|
||||
|
|
@ -82,6 +83,7 @@ export function NewWoWizard({
|
|||
locationDetailLoading={wizard.locationDetailLoading}
|
||||
locationDetailError={wizard.locationDetailError}
|
||||
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
|
||||
showErrors={wizard.showErrors}
|
||||
svcOpen={wizard.svcOpen}
|
||||
onSvcOpenChange={wizard.setSvcOpen}
|
||||
svcSearch={wizard.svcSearch}
|
||||
|
|
@ -108,8 +110,15 @@ export function NewWoWizard({
|
|||
onCancel={() => {
|
||||
if (!wizard.isCreating) onOpenChange(false);
|
||||
}}
|
||||
onBack={() => wizard.setStep((s) => s - 1)}
|
||||
onContinue={() => wizard.setStep((s) => s + 1)}
|
||||
onBack={() => {
|
||||
wizard.setShowErrors(false);
|
||||
wizard.setStep((s) => s - 1);
|
||||
}}
|
||||
onContinue={() => {
|
||||
wizard.setShowErrors(false);
|
||||
wizard.setStep((s) => s + 1);
|
||||
}}
|
||||
onAttemptContinue={() => wizard.setShowErrors(true)}
|
||||
onCreate={wizard.handleCreate}
|
||||
isCreating={wizard.isCreating}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export function useNewWoWizardDraft(open: boolean) {
|
|||
});
|
||||
const [svcOpen, setSvcOpen] = useState(false);
|
||||
const [svcSearch, setSvcSearch] = useState("");
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -20,6 +21,7 @@ export function useNewWoWizardDraft(open: boolean) {
|
|||
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
|
||||
setSvcOpen(false);
|
||||
setSvcSearch("");
|
||||
setShowErrors(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -45,5 +47,7 @@ export function useNewWoWizardDraft(open: boolean) {
|
|||
setSvcOpen,
|
||||
svcSearch,
|
||||
setSvcSearch,
|
||||
showErrors,
|
||||
setShowErrors,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export function useNewWoWizardState({
|
|||
}: UseNewWoWizardStateArgs) {
|
||||
const mockFallback = isWoWizardMockEnabled();
|
||||
const [step, setStep] = useState(1);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
status: "Incomplete",
|
||||
|
|
@ -81,6 +82,7 @@ export function useNewWoWizardState({
|
|||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep(1);
|
||||
setShowErrors(false);
|
||||
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
|
||||
setPocAutoFilledFor("");
|
||||
setDuplicateRow(null);
|
||||
|
|
@ -119,6 +121,8 @@ export function useNewWoWizardState({
|
|||
return {
|
||||
step,
|
||||
setStep,
|
||||
showErrors,
|
||||
setShowErrors,
|
||||
draft,
|
||||
duplicateRow,
|
||||
setDuplicateRow,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { Button } from "@/components/ui/button";
|
|||
import {
|
||||
WIZARD_STEPS,
|
||||
canContinueWizardStep1,
|
||||
canContinueWizardStep2,
|
||||
canCreateWorkOrderFromWizard,
|
||||
isWizardStepValid,
|
||||
isWizardWoNumberValid,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
|
|
@ -15,9 +17,24 @@ type WizardFooterProps = {
|
|||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
onCreate: () => void;
|
||||
onAttemptContinue?: () => void;
|
||||
isCreating: boolean;
|
||||
};
|
||||
|
||||
function stepHint(step: number, draft: WorkOrderWizardDraft): string | null {
|
||||
if (step === 1 && !canContinueWizardStep1(draft)) {
|
||||
if (!isWizardWoNumberValid(draft.woNumber)) {
|
||||
return "Enter an alphanumeric WO number, or leave blank for SH generation";
|
||||
}
|
||||
return "Select severity to continue";
|
||||
}
|
||||
if (step === 2 && !canContinueWizardStep2(draft)) {
|
||||
if (!draft.locationId) return "Pick a site to continue";
|
||||
return "Enter POC name and phone to continue";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function WizardFooter({
|
||||
step,
|
||||
draft,
|
||||
|
|
@ -25,11 +42,12 @@ export function WizardFooter({
|
|||
onBack,
|
||||
onContinue,
|
||||
onCreate,
|
||||
onAttemptContinue,
|
||||
isCreating,
|
||||
}: WizardFooterProps) {
|
||||
const stepValid = isWizardStepValid(step, draft);
|
||||
const canCreate = canCreateWorkOrderFromWizard(draft);
|
||||
const canContinue1 = canContinueWizardStep1(draft);
|
||||
const hint = stepHint(step, draft);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -60,20 +78,25 @@ export function WizardFooter({
|
|||
)}
|
||||
|
||||
<div className="flex flex-1 justify-center">
|
||||
{step === 1 && !canContinue1 && (
|
||||
{Boolean(hint) && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
<Lightbulb size={13} /> Pick a type to continue
|
||||
<Lightbulb size={13} /> {hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step < WIZARD_STEPS.length ? (
|
||||
<Button
|
||||
disabled={!stepValid}
|
||||
onClick={onContinue}
|
||||
onClick={() => {
|
||||
if (!stepValid) {
|
||||
onAttemptContinue?.();
|
||||
return;
|
||||
}
|
||||
onContinue();
|
||||
}}
|
||||
endIcon={<ChevronRight size={14} />}
|
||||
sx={{
|
||||
background: "var(--color-primary)",
|
||||
|
|
@ -81,17 +104,23 @@ export function WizardFooter({
|
|||
height: 36,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
...(!stepValid ? { opacity: 0.5, pointerEvents: "none" } : {}),
|
||||
...(!stepValid ? { opacity: 0.7 } : {}),
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canCreate || isCreating}
|
||||
disabled={isCreating}
|
||||
loading={isCreating}
|
||||
loadingText="Creating…"
|
||||
onClick={onCreate}
|
||||
onClick={() => {
|
||||
if (!canCreate) {
|
||||
onAttemptContinue?.();
|
||||
return;
|
||||
}
|
||||
onCreate();
|
||||
}}
|
||||
startIcon={!isCreating ? <Check size={14} /> : undefined}
|
||||
sx={{
|
||||
background: "var(--color-primary)",
|
||||
|
|
@ -99,7 +128,7 @@ export function WizardFooter({
|
|||
height: 36,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
...(!canCreate ? { opacity: 0.5, pointerEvents: "none" } : {}),
|
||||
...(!canCreate ? { opacity: 0.7 } : {}),
|
||||
}}
|
||||
>
|
||||
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_STYLE,
|
||||
} 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 = {
|
||||
draft: WorkOrderWizardDraft;
|
||||
showErrors?: boolean;
|
||||
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 (
|
||||
<div>
|
||||
<WizSubLabel>Point of contact</WizSubLabel>
|
||||
<WizLabel required>Point of contact</WizLabel>
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
|
|
@ -38,6 +45,11 @@ export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocat
|
|||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
{Boolean(showErrors && pocIncomplete) && (
|
||||
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
||||
POC name and phone are required.
|
||||
</Text>
|
||||
)}
|
||||
<textarea
|
||||
value={draft.pocNotes}
|
||||
onChange={(e) => onUpdate("pocNotes", e.target.value)}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type WizardStepLocationServiceProps = {
|
|||
locationDetailLoading?: boolean;
|
||||
locationDetailError?: boolean;
|
||||
services: ServiceOption[];
|
||||
showErrors?: boolean;
|
||||
svcOpen: boolean;
|
||||
onSvcOpenChange: (open: boolean) => void;
|
||||
svcSearch: string;
|
||||
|
|
@ -31,6 +32,7 @@ export function WizardStepLocationService({
|
|||
locationDetailLoading,
|
||||
locationDetailError,
|
||||
services,
|
||||
showErrors = false,
|
||||
svcOpen,
|
||||
onSvcOpenChange,
|
||||
svcSearch,
|
||||
|
|
@ -57,7 +59,7 @@ export function WizardStepLocationService({
|
|||
locationDetailLoading={locationDetailLoading}
|
||||
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 { Text } from "@/components/ui/text";
|
||||
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 { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
|
|
@ -15,6 +24,7 @@ import {
|
|||
WizLabel,
|
||||
WizLabelRow,
|
||||
} 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 type { ElementType } from "react";
|
||||
|
||||
|
|
@ -26,6 +36,17 @@ const WO_TYPE_ICONS: Record<WOType, ElementType> = {
|
|||
Overdue: Clock,
|
||||
};
|
||||
|
||||
function nextSeverityForType(
|
||||
currentType: WOType | "",
|
||||
nextType: WOType,
|
||||
currentSeverity: WorkOrderSeverity | null,
|
||||
): WorkOrderSeverity | null {
|
||||
if (!typeNeedsSeverity(nextType) || currentType !== nextType) {
|
||||
return null;
|
||||
}
|
||||
return currentSeverity;
|
||||
}
|
||||
|
||||
function WoTypePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
|
|
@ -113,73 +134,14 @@ function WoTypePicker({
|
|||
);
|
||||
}
|
||||
|
||||
type WizardStepTypeScheduleProps = {
|
||||
type WizardTypeScheduleDatesProps = {
|
||||
draft: WorkOrderWizardDraft;
|
||||
users: WorkOrderUser[];
|
||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||
onDuplicateWoNumber?: (woNumber: string) => void;
|
||||
};
|
||||
|
||||
export function WizardStepTypeSchedule({
|
||||
draft,
|
||||
users,
|
||||
onUpdate,
|
||||
onPatch,
|
||||
onDuplicateWoNumber,
|
||||
}: WizardStepTypeScheduleProps) {
|
||||
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
|
||||
|
||||
function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeScheduleDatesProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<WizLabel required>Type of WO</WizLabel>
|
||||
<WoTypePicker selected={draft.type} onSelect={(t) => onUpdate("type", t)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<WizLabel optional>WO Number</WizLabel>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draft.woNumber}
|
||||
onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))}
|
||||
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
|
||||
placeholder="e.g. 12345678901"
|
||||
maxLength={20}
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<WizLabel optional>Assigned To</WizLabel>
|
||||
<WizardFieldSelect
|
||||
value={draft.assigneeId}
|
||||
placeholder="Select…"
|
||||
icon={
|
||||
selectedUser ? (
|
||||
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />
|
||||
) : (
|
||||
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
)
|
||||
}
|
||||
searchPlaceholder="Search dispatcher…"
|
||||
options={users.map((u) => ({
|
||||
key: String(u.id),
|
||||
label: u.name,
|
||||
left: <ColorDot color={getDispatcherAvatarColor(u)} />,
|
||||
}))}
|
||||
onPick={(id) => {
|
||||
const user = users.find((u) => String(u.id) === id);
|
||||
onPatch({
|
||||
assigneeId: id,
|
||||
assigneeName: user?.name ?? "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
<div>
|
||||
<WizLabelRow
|
||||
|
|
@ -251,6 +213,103 @@ export function WizardStepTypeSchedule({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type WizardStepTypeScheduleProps = {
|
||||
draft: WorkOrderWizardDraft;
|
||||
users: WorkOrderUser[];
|
||||
showErrors?: boolean;
|
||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||
onDuplicateWoNumber?: (woNumber: string) => void;
|
||||
};
|
||||
|
||||
export function WizardStepTypeSchedule({
|
||||
draft,
|
||||
users,
|
||||
showErrors = false,
|
||||
onUpdate,
|
||||
onPatch,
|
||||
onDuplicateWoNumber,
|
||||
}: WizardStepTypeScheduleProps) {
|
||||
const needsSeverity = typeNeedsSeverity(draft.type);
|
||||
const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber);
|
||||
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<WizLabel optional>Type of WO</WizLabel>
|
||||
<WoTypePicker
|
||||
selected={draft.type}
|
||||
onSelect={(t) => {
|
||||
onPatch({
|
||||
type: t,
|
||||
severity: nextSeverityForType(draft.type, t, draft.severity),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Boolean(needsSeverity) && (
|
||||
<WizardSeverityPicker
|
||||
value={draft.severity}
|
||||
showError={showErrors}
|
||||
onSelect={(severity) => onUpdate("severity", severity)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<WizLabel optional>WO Number</WizLabel>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.woNumber}
|
||||
onChange={(e) =>
|
||||
onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20))
|
||||
}
|
||||
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
|
||||
placeholder="Leave blank for SH provisional number"
|
||||
maxLength={20}
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
{Boolean(woNumberError) && (
|
||||
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
||||
WO number must be alphanumeric.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<WizLabel optional>Assigned To</WizLabel>
|
||||
<WizardFieldSelect
|
||||
value={draft.assigneeId}
|
||||
placeholder="Select…"
|
||||
icon={
|
||||
selectedUser ? (
|
||||
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />
|
||||
) : (
|
||||
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
)
|
||||
}
|
||||
searchPlaceholder="Search dispatcher…"
|
||||
options={users.map((u) => ({
|
||||
key: String(u.id),
|
||||
label: u.name,
|
||||
left: <ColorDot color={getDispatcherAvatarColor(u)} />,
|
||||
}))}
|
||||
onPick={(id) => {
|
||||
const user = users.find((u) => String(u.id) === id);
|
||||
onPatch({
|
||||
assigneeId: id,
|
||||
assigneeName: user?.name ?? "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,4 +53,8 @@ export function assignWizardServicePocFields(
|
|||
if (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. */
|
||||
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
||||
{ 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: "site", field: "siteCode", 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 {
|
||||
WOType,
|
||||
WizardWOStatus,
|
||||
WorkOrderWizardDraft,
|
||||
import {
|
||||
effectiveWizardType,
|
||||
type WOType,
|
||||
type WizardWOStatus,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
|
||||
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 {
|
||||
const typeLabel = effectiveWizardType(draft.type);
|
||||
if (draft.woNumber) {
|
||||
return `${draft.type || "Work Order"} — #${draft.woNumber}`;
|
||||
return `${typeLabel} — #${draft.woNumber}`;
|
||||
}
|
||||
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 {
|
||||
|
|
@ -103,7 +105,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
|
|||
workOrderNumber: draft.woNumber,
|
||||
title: buildTitle(draft),
|
||||
locationId: draft.locationId,
|
||||
priority: mapTypeToPriority(draft.type),
|
||||
priority: mapTypeToPriority(effectiveWizardType(draft.type)),
|
||||
status: mapWizardStatusToApi(draft.status),
|
||||
createdDate: todayIso(),
|
||||
dueDate: draft.dueDate,
|
||||
|
|
@ -118,6 +120,6 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
|
|||
trade: "",
|
||||
subTrade: "",
|
||||
vendorNTE: "",
|
||||
source: draft.type,
|
||||
source: effectiveWizardType(draft.type),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
|
|||
export function mapWizardDraftToBoardCreatePayload(
|
||||
draft: WorkOrderWizardDraft,
|
||||
): WorkOrderBoardCreatePayload {
|
||||
const workOrderType = mapWizardTypeToApi(draft.type);
|
||||
const workOrderType = mapWizardTypeToApi(draft.type || "PM");
|
||||
|
||||
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
CompletionDocStatus,
|
||||
WorkOrderTableRow,
|
||||
} 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 { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
|
@ -234,6 +235,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
woNumber,
|
||||
site: site || location.slice(0, 8),
|
||||
type: readWoType(item),
|
||||
severity: parseWorkOrderSeverity(item.severity ?? item.Severity),
|
||||
dispatcherId,
|
||||
dispatcherName,
|
||||
scheduledOn,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ export interface WorkOrderBoardCreatePayload {
|
|||
pocNotes?: string;
|
||||
techPhone?: string;
|
||||
vendorNotes?: string;
|
||||
/** Severity 1–5 for Emergency/Reactive. */
|
||||
severity?: number;
|
||||
}
|
||||
|
||||
/** Payload for PATCH /workorders/{id}/board (one field per request). */
|
||||
|
|
@ -122,7 +124,8 @@ export type WorkOrderBoardEditableField =
|
|||
| "pm"
|
||||
| "serviceNotes"
|
||||
| "extraServices"
|
||||
| "flagColor";
|
||||
| "flagColor"
|
||||
| "severity";
|
||||
|
||||
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
|
||||
export interface WorkOrderBoardRowApi {
|
||||
|
|
@ -147,6 +150,8 @@ export interface WorkOrderBoardRowApi {
|
|||
color?: string | null;
|
||||
/** Board flag color (#RRGGBB) or null when cleared. */
|
||||
flagColor?: string | null;
|
||||
/** Severity 1–5 for Emergency/Reactive. */
|
||||
severity?: number | null;
|
||||
dueDate?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
/** 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 { WorkOrderSeverity } from "./work-order-severity";
|
||||
|
||||
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||
|
||||
|
|
@ -9,6 +10,8 @@ export interface WorkOrderTableRow {
|
|||
woNumber: string;
|
||||
site: string;
|
||||
type: WOType | "";
|
||||
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
|
||||
severity: WorkOrderSeverity | null;
|
||||
dispatcherId: string;
|
||||
dispatcherName: string;
|
||||
scheduledOn: string;
|
||||
|
|
@ -57,6 +60,7 @@ export interface WorkOrderTableDraft {
|
|||
site: string;
|
||||
woNumber: string;
|
||||
type: WOType | "";
|
||||
severity: WorkOrderSeverity | null;
|
||||
dispatcherId: string;
|
||||
scheduledOn: string;
|
||||
schedNotes: string;
|
||||
|
|
@ -84,6 +88,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
site: "",
|
||||
woNumber: "",
|
||||
type: "",
|
||||
severity: null,
|
||||
dispatcherId: "",
|
||||
scheduledOn: "",
|
||||
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 type WOType = (typeof WO_TYPES)[number];
|
||||
|
|
@ -24,6 +28,8 @@ export interface WorkOrderWizardDraft {
|
|||
siteCode: string;
|
||||
woNumber: string;
|
||||
type: WOType | "";
|
||||
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
|
||||
severity: WorkOrderSeverity | null;
|
||||
assigneeId: string;
|
||||
assigneeName: string;
|
||||
scheduledOn: string;
|
||||
|
|
@ -58,6 +64,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
siteCode: "",
|
||||
woNumber: "",
|
||||
type: "",
|
||||
severity: null,
|
||||
assigneeId: "",
|
||||
assigneeName: "",
|
||||
scheduledOn: "",
|
||||
|
|
@ -81,13 +88,22 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
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 {
|
||||
const trimmed = woNumber.trim();
|
||||
if (!trimmed) {
|
||||
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 }[] = [
|
||||
|
|
@ -159,11 +175,22 @@ export const TIME_SLOTS: string[] = (() => {
|
|||
})();
|
||||
|
||||
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 {
|
||||
return Boolean(draft.type && draft.siteCode) && isWizardWoNumberValid(draft.woNumber);
|
||||
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
|
||||
}
|
||||
|
||||
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
|
||||
|
|
@ -171,7 +198,7 @@ export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): bo
|
|||
return canContinueWizardStep1(draft);
|
||||
}
|
||||
if (step === 2) {
|
||||
return Boolean(draft.locationId && draft.siteCode);
|
||||
return canContinueWizardStep2(draft);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,12 @@ vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast"
|
|||
const SCHEDULED_ON = "2026-06-01";
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
const defaults: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: SCHEDULED_ON,
|
||||
|
|
@ -71,7 +72,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
...overrides,
|
||||
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ function baseRow(): WorkOrderTableRow {
|
|||
woNumber: "WO-7",
|
||||
site: "SEA1",
|
||||
type: "Emergency",
|
||||
severity: 1,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
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, {
|
||||
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, {
|
||||
target: { value: "98765432109" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
|||
}));
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
const defaults: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-06-01",
|
||||
|
|
@ -58,7 +59,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
...overrides,
|
||||
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
woNumber: "100",
|
||||
site: "SITE",
|
||||
type: "Emergency",
|
||||
severity: null,
|
||||
dispatcherId: "d1",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-16",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const unscheduledRow: WorkOrderTableRow = {
|
|||
woNumber: "WO-42",
|
||||
site: "CHI-1042",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Test Dispatcher",
|
||||
scheduledOn: "",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-07-20",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ const canceledRow: WorkOrderTableRow = {
|
|||
woNumber: "WO-77",
|
||||
site: "CHI-1042",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Test Dispatcher",
|
||||
scheduledOn: "2026-07-20",
|
||||
|
|
@ -216,9 +217,15 @@ describe("WorkOrdersListPage board-core public interface", () => {
|
|||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/pick a type to continue/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
|
||||
expect(
|
||||
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 }));
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-
|
|||
import {
|
||||
EMPTY_WIZARD_DRAFT,
|
||||
canContinueWizardStep1,
|
||||
canContinueWizardStep2,
|
||||
canCreateWorkOrderFromWizard,
|
||||
isWizardStepValid,
|
||||
isWizardWoNumberValid,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
describe("mapWizardDraftToFormValues", () => {
|
||||
|
|
@ -63,12 +65,18 @@ describe("mapWizardDraftToFormValues", () => {
|
|||
});
|
||||
|
||||
describe("wizard step validation", () => {
|
||||
it("blocks step 1 continue without type", () => {
|
||||
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(false);
|
||||
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false);
|
||||
it("allows step 1 continue without a woNumber for backend SH generation", () => {
|
||||
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true);
|
||||
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 };
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||
|
|
@ -84,6 +92,51 @@ describe("wizard step validation", () => {
|
|||
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", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
|
@ -95,44 +148,54 @@ describe("wizard step validation", () => {
|
|||
expect(isWizardStepValid(2, draft)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows create when type and siteCode are set (woNumber optional)", () => {
|
||||
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", () => {
|
||||
it("allows create when type, woNumber, site, and POC are set", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "12345678901234567890",
|
||||
locationId: "99",
|
||||
siteCode: "BK5",
|
||||
pocName: "Jordan",
|
||||
pocPhone: "555-0100",
|
||||
};
|
||||
expect(canCreateWorkOrderFromWizard(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 = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "",
|
||||
};
|
||||
expect(isWizardWoNumberValid(draft.woNumber)).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 = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
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);
|
||||
});
|
||||
|
||||
it("throws when type is missing", () => {
|
||||
expect(() =>
|
||||
mapWizardDraftToBoardCreatePayload({
|
||||
it("defaults empty type to PM", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
siteCode: "BK5",
|
||||
}),
|
||||
).toThrow("Work order type is required");
|
||||
});
|
||||
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.PM);
|
||||
});
|
||||
|
||||
it("throws when siteCode cannot be resolved", () => {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
expect(expandTablePatchToBoardFieldOps({ flagColor: null })).toEqual([
|
||||
{ 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", () => {
|
||||
|
|
|
|||
|
|
@ -147,11 +147,13 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
|
|||
docStatus: 1,
|
||||
dayGroup: "wednesday",
|
||||
flagColor: "#EF4444",
|
||||
severity: 2,
|
||||
});
|
||||
|
||||
expect(row.woNumber).toBe("10000001235");
|
||||
expect(row.site).toBe("BK5");
|
||||
expect(row.type).toBe("Reactive");
|
||||
expect(row.severity).toBe(2);
|
||||
expect(row.status).toBe("Scheduled");
|
||||
expect(row.scheduledOn).toBe("2026-07-15");
|
||||
expect(row.dueDate).toBe("2026-07-18");
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const BASE_ROW = {
|
|||
id: 99,
|
||||
rowVersion: "AAAAAAA=",
|
||||
completedDate: "",
|
||||
severity: null,
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
describe("runWorkOrderTablePatch completedDate", () => {
|
||||
|
|
|
|||
|
|
@ -50,11 +50,12 @@ const BASE_SERVER: ServerState = {
|
|||
docStatus: "No",
|
||||
};
|
||||
|
||||
const BASE_ROW = {
|
||||
const BASE_ROW: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "d1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-06-01",
|
||||
|
|
@ -93,7 +94,7 @@ const BASE_ROW = {
|
|||
rowVersion: "AAAAAAA=",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
} as WorkOrderTableRow;
|
||||
};
|
||||
|
||||
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||
let serverState: ServerState;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
woNumber: "1001",
|
||||
site: "SITE-A",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "10",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-16",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
|||
woNumber: "1001",
|
||||
site: "SITE-A",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "10",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-16",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
woNumber: "12345678901",
|
||||
site: "SITE01",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alice",
|
||||
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