diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 0edec077..539a8427 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -21,6 +21,7 @@ export function buildInlineDraftCreateInput( siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }), woNumber: draft.woNumber, type: draft.type || "PM", + severity: draft.severity, assigneeId: draft.dispatcherId, assigneeName: assignee?.name ?? "", scheduledOn: draft.scheduledOn, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx new file mode 100644 index 00000000..18e3092a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx @@ -0,0 +1,39 @@ +import { Tooltip } from "@mui/material"; + +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; + +type SeverityBadgeProps = { + severity: WorkOrderSeverity; + onClick?: (e: React.MouseEvent) => void; +}; + +export function SeverityBadge({ severity, onClick }: SeverityBadgeProps) { + const lvl = SEVERITY_LEVELS[severity]; + const color = SEVERITY_TOKEN_COLOR[lvl.token]; + const title = `${lvl.description} · ${lvl.deadline} · After hours: ${lvl.afterHours}`; + + return ( + + + {lvl.label} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx new file mode 100644 index 00000000..d78961af --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx @@ -0,0 +1,86 @@ +import { Text } from "@/components/ui/text"; +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + WORK_ORDER_SEVERITIES, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; + +type SeverityOptionsListProps = { + value: WorkOrderSeverity | null; + onSelect: (severity: WorkOrderSeverity) => void; +}; + +export function SeverityOptionsList({ value, onSelect }: SeverityOptionsListProps) { + return ( +
+ {WORK_ORDER_SEVERITIES.map((n) => { + const lvl = SEVERITY_LEVELS[n]; + const color = SEVERITY_TOKEN_COLOR[lvl.token]; + const selected = value === n; + return ( + + ); + })} +
+ ); +} + +type SeverityFieldProps = { + value: WorkOrderSeverity | null; + onSelect: (severity: WorkOrderSeverity) => void; +}; + +export function SeverityField({ value, onSelect }: SeverityFieldProps) { + return ( +
+ + Severity (from APM) + + + Transcribe the severity already assigned on the client's ticket — this is not a + judgment call. + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx index 4ac6fe60..7421cfac 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx @@ -1,25 +1,49 @@ import { useState } from "react"; import { Check } from "lucide-react"; - import { Popover } from "@mui/material"; +import { + typeNeedsSeverity, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; import { HoverChevron } from "./hover-chevron"; - +import { SeverityBadge } from "./severity-badge"; +import { TypeSeverityModal } from "./type-severity-modal"; import { WoTableTypeLabel } from "./wo-table-type-label"; type TypeCellProps = { value: WOType | ""; - + severity?: WorkOrderSeverity | null; q?: string; - - onSave: (v: WOType) => void; + onSave: (type: WOType, severity: WorkOrderSeverity | null) => void; }; -export function TypeCell({ value, q, onSave }: TypeCellProps) { +export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [modalSeed, setModalSeed] = useState(value); + const [resetSeverity, setResetSeverity] = useState(false); + + function pick(t: WOType) { + setAnchorEl(null); + const needsSeverity = typeNeedsSeverity(t); + const switchingSeverityType = + needsSeverity && value !== "" && typeNeedsSeverity(value) && t !== value; + + if (needsSeverity && (severity == null || switchingSeverityType)) { + setModalSeed(t); + setResetSeverity(Boolean(switchingSeverityType && severity != null)); + setModalOpen(true); + return; + } + + onSave(t, needsSeverity ? severity : null); + } + + const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null); return ( <> @@ -27,7 +51,6 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) { type="button" onClick={(e) => { e.stopPropagation(); - setAnchorEl(e.currentTarget); }} className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80" @@ -37,7 +60,17 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) { ) : ( Select … )} - + {Boolean(showSeverityBadge && severity != null) && ( + { + e.stopPropagation(); + setModalSeed(value); + setResetSeverity(false); + setModalOpen(true); + }} + /> + )} @@ -53,19 +86,22 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) { ))} + + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/type-severity-modal.tsx b/src/app/(protected)/workorders/_components/list/table/cells/type-severity-modal.tsx new file mode 100644 index 00000000..1d9ded63 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/type-severity-modal.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; + +import { Text } from "@/components/ui/text"; +import { + typeNeedsSeverity, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; +import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; + +import { SeverityField } from "./severity-options-list"; +import { WoTableTypeLabel } from "./wo-table-type-label"; + +type TypeSeverityModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + type: WOType | ""; + severity: WorkOrderSeverity | null; + onSave: (type: WOType, severity: WorkOrderSeverity | null) => void; +}; + +export function TypeSeverityModal({ + open, + onOpenChange, + type, + severity, + onSave, +}: TypeSeverityModalProps) { + const [draftType, setDraftType] = useState(type); + const [draftSeverity, setDraftSeverity] = useState(severity); + + useEffect(() => { + if (open) { + setDraftType(type); + setDraftSeverity(severity); + } + }, [open, type, severity]); + + const needsSeverity = typeNeedsSeverity(draftType); + + function pickType(t: WOType) { + if (typeNeedsSeverity(t)) { + if (t !== draftType) { + setDraftSeverity(null); + } + setDraftType(t); + return; + } + onSave(t, null); + onOpenChange(false); + } + + function confirm() { + if (!draftType || (needsSeverity && draftSeverity == null)) { + return; + } + onSave(draftType, needsSeverity ? draftSeverity : null); + onOpenChange(false); + } + + const canConfirm = Boolean(draftType) && (!needsSeverity || draftSeverity != null); + + return ( + onOpenChange(false)} + maxWidth="xs" + fullWidth + onClick={(e) => e.stopPropagation()} + slotProps={{ + paper: { + sx: { borderRadius: 2, background: "var(--color-bg-surface)" }, + }, + }} + > + + Type & severity + + +
+
+ + Type + +
+ {WO_TYPES.map((t) => ( + + ))} +
+
+ {Boolean(needsSeverity) && ( + + )} +
+
+ + + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index 28b01db3..66fce868 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -37,6 +37,24 @@ type WoTableInlineRowCellsProps = { onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; +function InlineTypeSeverityCell({ + draft, + onPatch, +}: { + draft: WorkOrderTableDraft; + onPatch: (patch: Partial) => void; +}) { + return ( + + onPatch({ type: t, severity: sev })} + /> + + ); +} + function InlineMutedDashCell() { return ( @@ -81,9 +99,7 @@ export function WoTableInlineRowCells({ onDuplicateFound={onDuplicateFound} /> - - onPatch({ type: t })} /> - + e.stopPropagation()} > - onPatchRow({ type: t })} /> + onPatchRow({ type: t, severity: sev })} + /> ({ id: p.id, value: p.value }))} + showErrors={wizard.showErrors} svcOpen={wizard.svcOpen} onSvcOpenChange={wizard.setSvcOpen} svcSearch={wizard.svcSearch} @@ -108,8 +110,15 @@ export function NewWoWizard({ onCancel={() => { if (!wizard.isCreating) onOpenChange(false); }} - onBack={() => wizard.setStep((s) => s - 1)} - onContinue={() => wizard.setStep((s) => s + 1)} + onBack={() => { + wizard.setShowErrors(false); + wizard.setStep((s) => s - 1); + }} + onContinue={() => { + wizard.setShowErrors(false); + wizard.setStep((s) => s + 1); + }} + onAttemptContinue={() => wizard.setShowErrors(true)} onCreate={wizard.handleCreate} isCreating={wizard.isCreating} /> diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts index a3f6a348..c0d45525 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts @@ -13,6 +13,7 @@ export function useNewWoWizardDraft(open: boolean) { }); const [svcOpen, setSvcOpen] = useState(false); const [svcSearch, setSvcSearch] = useState(""); + const [showErrors, setShowErrors] = useState(false); useEffect(() => { if (open) { @@ -20,6 +21,7 @@ export function useNewWoWizardDraft(open: boolean) { setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); setSvcOpen(false); setSvcSearch(""); + setShowErrors(false); } }, [open]); @@ -45,5 +47,7 @@ export function useNewWoWizardDraft(open: boolean) { setSvcOpen, svcSearch, setSvcSearch, + showErrors, + setShowErrors, }; } diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts index 748fb3c5..8f6b1e66 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts @@ -37,6 +37,7 @@ export function useNewWoWizardState({ }: UseNewWoWizardStateArgs) { const mockFallback = isWoWizardMockEnabled(); const [step, setStep] = useState(1); + const [showErrors, setShowErrors] = useState(false); const [draft, setDraft] = useState({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete", @@ -81,6 +82,7 @@ export function useNewWoWizardState({ useEffect(() => { if (open) { setStep(1); + setShowErrors(false); setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); setPocAutoFilledFor(""); setDuplicateRow(null); @@ -119,6 +121,8 @@ export function useNewWoWizardState({ return { step, setStep, + showErrors, + setShowErrors, draft, duplicateRow, setDuplicateRow, diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx index 4240f19a..6f01be6a 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx @@ -3,8 +3,10 @@ import { Button } from "@/components/ui/button"; import { WIZARD_STEPS, canContinueWizardStep1, + canContinueWizardStep2, canCreateWorkOrderFromWizard, isWizardStepValid, + isWizardWoNumberValid, type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; @@ -15,9 +17,24 @@ type WizardFooterProps = { onBack: () => void; onContinue: () => void; onCreate: () => void; + onAttemptContinue?: () => void; isCreating: boolean; }; +function stepHint(step: number, draft: WorkOrderWizardDraft): string | null { + if (step === 1 && !canContinueWizardStep1(draft)) { + if (!isWizardWoNumberValid(draft.woNumber)) { + return "Enter an alphanumeric WO number, or leave blank for SH generation"; + } + return "Select severity to continue"; + } + if (step === 2 && !canContinueWizardStep2(draft)) { + if (!draft.locationId) return "Pick a site to continue"; + return "Enter POC name and phone to continue"; + } + return null; +} + export function WizardFooter({ step, draft, @@ -25,11 +42,12 @@ export function WizardFooter({ onBack, onContinue, onCreate, + onAttemptContinue, isCreating, }: WizardFooterProps) { const stepValid = isWizardStepValid(step, draft); const canCreate = canCreateWorkOrderFromWizard(draft); - const canContinue1 = canContinueWizardStep1(draft); + const hint = stepHint(step, draft); return (
- {step === 1 && !canContinue1 && ( + {Boolean(hint) && ( - Pick a type to continue + {hint} )}
{step < WIZARD_STEPS.length ? ( ) : (