mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 12:22:03 +00:00
merge(dev): refresh SH-196 branch onto current origin/dev
This commit is contained in:
commit
65e2741fd0
33 changed files with 1687 additions and 555 deletions
|
|
@ -0,0 +1,71 @@
|
||||||
|
export type MiniCalendarCellStyle = {
|
||||||
|
bg: string;
|
||||||
|
fg: string;
|
||||||
|
radius: number | string;
|
||||||
|
filled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function rangeBounds(rangeStart: string, rangeEnd: string): { lo: string; hi: string } {
|
||||||
|
if (!rangeEnd) {
|
||||||
|
return { lo: rangeStart, hi: rangeStart };
|
||||||
|
}
|
||||||
|
return rangeStart <= rangeEnd
|
||||||
|
? { lo: rangeStart, hi: rangeEnd }
|
||||||
|
: { lo: rangeEnd, hi: rangeStart };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeCellStyle(
|
||||||
|
iso: string,
|
||||||
|
rangeStart: string,
|
||||||
|
rangeEnd: string,
|
||||||
|
): MiniCalendarCellStyle | null {
|
||||||
|
const { lo, hi } = rangeBounds(rangeStart, rangeEnd);
|
||||||
|
const filledStyle = { fg: "var(--color-primary-foreground)", bg: "var(--color-primary)" };
|
||||||
|
|
||||||
|
if (lo === hi) {
|
||||||
|
return iso === lo ? { ...filledStyle, radius: 4, filled: true } : null;
|
||||||
|
}
|
||||||
|
if (iso === lo) {
|
||||||
|
return { ...filledStyle, radius: "4px 0 0 4px", filled: true };
|
||||||
|
}
|
||||||
|
if (iso === hi) {
|
||||||
|
return { ...filledStyle, radius: "0 4px 4px 0", filled: true };
|
||||||
|
}
|
||||||
|
if (iso > lo && iso < hi) {
|
||||||
|
return { bg: "var(--color-primary-tint)", fg: "var(--color-primary)", radius: 0, filled: true };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the visual state of a single day cell for both single-value and range selection. */
|
||||||
|
export function getMiniCalendarCellStyle(args: {
|
||||||
|
iso: string;
|
||||||
|
value: string;
|
||||||
|
isToday: boolean;
|
||||||
|
range?: boolean;
|
||||||
|
rangeStart?: string;
|
||||||
|
rangeEnd?: string;
|
||||||
|
}): MiniCalendarCellStyle {
|
||||||
|
const { iso, value, isToday, range, rangeStart = "", rangeEnd = "" } = args;
|
||||||
|
const defaultStyle: MiniCalendarCellStyle = {
|
||||||
|
bg: "transparent",
|
||||||
|
fg: isToday ? "var(--color-primary)" : "var(--color-text-primary)",
|
||||||
|
radius: 4,
|
||||||
|
filled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (range && rangeStart) {
|
||||||
|
return rangeCellStyle(iso, rangeStart, rangeEnd) ?? defaultStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iso === value) {
|
||||||
|
return {
|
||||||
|
bg: "var(--color-primary)",
|
||||||
|
fg: "var(--color-primary-foreground)",
|
||||||
|
radius: 4,
|
||||||
|
filled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultStyle;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { getMiniCalendarCellStyle } from "@/app/(protected)/workorders/_components/wizard/mini-calendar-cell-style";
|
||||||
import { MONTH_NAMES, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { MONTH_NAMES, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
|
const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
@ -7,10 +8,21 @@ const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
type MiniCalendarProps = {
|
type MiniCalendarProps = {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (iso: string) => void;
|
onChange: (iso: string) => void;
|
||||||
|
/** Range-selection mode — parent owns the click logic. */
|
||||||
|
range?: boolean;
|
||||||
|
rangeStart?: string;
|
||||||
|
rangeEnd?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
|
export function MiniCalendar({
|
||||||
const init = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : todayIso();
|
value,
|
||||||
|
onChange,
|
||||||
|
range,
|
||||||
|
rangeStart = "",
|
||||||
|
rangeEnd = "",
|
||||||
|
}: MiniCalendarProps) {
|
||||||
|
const initRaw = range ? rangeStart || value : value;
|
||||||
|
const init = /^\d{4}-\d{2}-\d{2}$/.test(initRaw) ? initRaw : todayIso();
|
||||||
const [iy, im] = init.split("-").map(Number);
|
const [iy, im] = init.split("-").map(Number);
|
||||||
const [viewY, setViewY] = useState(iy);
|
const [viewY, setViewY] = useState(iy);
|
||||||
const [viewM, setViewM] = useState(im);
|
const [viewM, setViewM] = useState(im);
|
||||||
|
|
@ -81,8 +93,16 @@ export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
|
||||||
return <span key={i} />;
|
return <span key={i} />;
|
||||||
}
|
}
|
||||||
const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
|
const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
|
||||||
const selected = iso === value;
|
|
||||||
const isToday = iso === today;
|
const isToday = iso === today;
|
||||||
|
const { bg, fg, radius, filled } = getMiniCalendarCellStyle({
|
||||||
|
iso,
|
||||||
|
value,
|
||||||
|
isToday,
|
||||||
|
range,
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
|
|
@ -91,16 +111,12 @@ export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
|
||||||
className="text-center text-[12px] transition-colors hover:bg-[var(--color-primary-tint)]"
|
className="text-center text-[12px] transition-colors hover:bg-[var(--color-primary-tint)]"
|
||||||
style={{
|
style={{
|
||||||
height: 26,
|
height: 26,
|
||||||
borderRadius: 4,
|
borderRadius: radius,
|
||||||
fontWeight: selected || isToday ? 600 : 400,
|
fontWeight: filled || isToday ? 600 : 400,
|
||||||
background: selected ? "var(--color-primary)" : "transparent",
|
background: filled ? bg : undefined,
|
||||||
color: selected
|
color: fg,
|
||||||
? "var(--color-primary-foreground)"
|
|
||||||
: isToday
|
|
||||||
? "var(--color-primary)"
|
|
||||||
: "var(--color-text-primary)",
|
|
||||||
border:
|
border:
|
||||||
isToday && !selected
|
isToday && !filled
|
||||||
? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)"
|
? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)"
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
import { Dialog } from "@mui/material";
|
import { Dialog } from "@mui/material";
|
||||||
|
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||||
|
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { WIZARD_STEPS } from "@/domain/work-orders/types/work-order-wizard";
|
|
||||||
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
|
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
|
||||||
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
|
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
|
||||||
|
import { WizardStepHeader } from "@/app/(protected)/workorders/_components/wizard/wizard-step-header";
|
||||||
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
|
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
|
||||||
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
||||||
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
||||||
import { useNewWoWizardState } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state";
|
import { useNewWoWizardController } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller";
|
||||||
|
|
||||||
type NewWoWizardProps = {
|
type NewWoWizardProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -20,46 +22,6 @@ type NewWoWizardProps = {
|
||||||
onViewExistingWo?: (row: WorkOrderTableRow) => void;
|
onViewExistingWo?: (row: WorkOrderTableRow) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function WizardStepHeader({ step }: { step: number }) {
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.08em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "var(--color-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Step {step} of {WIZARD_STEPS.length}
|
|
||||||
</div>
|
|
||||||
<Text
|
|
||||||
variant="title"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: 20,
|
|
||||||
color: "var(--foreground)",
|
|
||||||
marginTop: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{WIZARD_STEPS[step - 1].title}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
variant="description"
|
|
||||||
sx={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
marginTop: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{WIZARD_STEPS[step - 1].subtitle}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewWoWizard({
|
export function NewWoWizard({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -67,13 +29,13 @@ export function NewWoWizard({
|
||||||
existingRows = [],
|
existingRows = [],
|
||||||
onViewExistingWo,
|
onViewExistingWo,
|
||||||
}: NewWoWizardProps) {
|
}: NewWoWizardProps) {
|
||||||
const wizard = useNewWoWizardState({ open, users, existingRows, onOpenChange });
|
const wizard = useNewWoWizardController({ open, onOpenChange, existingRows });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={wizard.handleClose}
|
||||||
maxWidth={false}
|
maxWidth={false}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...dialogBlurBackdropSlotProps,
|
...dialogBlurBackdropSlotProps,
|
||||||
|
|
@ -85,13 +47,15 @@ export function NewWoWizard({
|
||||||
maxWidth: 768,
|
maxWidth: 768,
|
||||||
height: 680,
|
height: 680,
|
||||||
maxHeight: 680,
|
maxHeight: 680,
|
||||||
|
pointerEvents: wizard.isCreating ? "none" : undefined,
|
||||||
|
opacity: wizard.isCreating ? 0.85 : 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Guided setup for creating a new work order</span>
|
<span className="sr-only">Guided setup for creating a new work order</span>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1" aria-busy={wizard.isCreating || undefined}>
|
||||||
<WizardSidebar step={wizard.step} draft={wizard.draft} />
|
<WizardSidebar step={wizard.step} draft={wizard.draft} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -103,7 +67,7 @@ export function NewWoWizard({
|
||||||
{wizard.step === 1 && (
|
{wizard.step === 1 && (
|
||||||
<WizardStepTypeSchedule
|
<WizardStepTypeSchedule
|
||||||
draft={wizard.draft}
|
draft={wizard.draft}
|
||||||
users={wizard.wizardUsers}
|
users={users}
|
||||||
onUpdate={wizard.setField}
|
onUpdate={wizard.setField}
|
||||||
onPatch={wizard.patchDraft}
|
onPatch={wizard.patchDraft}
|
||||||
onDuplicateWoNumber={wizard.handleDuplicateFound}
|
onDuplicateWoNumber={wizard.handleDuplicateFound}
|
||||||
|
|
@ -115,7 +79,13 @@ export function NewWoWizard({
|
||||||
draft={wizard.draft}
|
draft={wizard.draft}
|
||||||
locations={wizard.locations}
|
locations={wizard.locations}
|
||||||
locationDetail={wizard.locationDetail}
|
locationDetail={wizard.locationDetail}
|
||||||
|
locationDetailLoading={wizard.locationDetailLoading}
|
||||||
|
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 }))}
|
||||||
|
svcOpen={wizard.svcOpen}
|
||||||
|
onSvcOpenChange={wizard.setSvcOpen}
|
||||||
|
svcSearch={wizard.svcSearch}
|
||||||
|
onSvcSearchChange={wizard.setSvcSearch}
|
||||||
onUpdate={wizard.setField}
|
onUpdate={wizard.setField}
|
||||||
onPatch={wizard.patchDraft}
|
onPatch={wizard.patchDraft}
|
||||||
/>
|
/>
|
||||||
|
|
@ -135,7 +105,9 @@ export function NewWoWizard({
|
||||||
<WizardFooter
|
<WizardFooter
|
||||||
step={wizard.step}
|
step={wizard.step}
|
||||||
draft={wizard.draft}
|
draft={wizard.draft}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => {
|
||||||
|
if (!wizard.isCreating) onOpenChange(false);
|
||||||
|
}}
|
||||||
onBack={() => wizard.setStep((s) => s - 1)}
|
onBack={() => wizard.setStep((s) => s - 1)}
|
||||||
onContinue={() => wizard.setStep((s) => s + 1)}
|
onContinue={() => wizard.setStep((s) => s + 1)}
|
||||||
onCreate={wizard.handleCreate}
|
onCreate={wizard.handleCreate}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||||
|
|
||||||
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
||||||
|
|
||||||
|
import { useWorkOrderVendors } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||||
|
|
||||||
|
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
|
||||||
|
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
|
||||||
|
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
|
||||||
|
|
||||||
|
type UseNewWoWizardControllerArgs = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
existingRows: WorkOrderTableRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wires draft state, lookups, POC autofill, and duplicate-WO guards for the create wizard. */
|
||||||
|
export function useNewWoWizardController({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
existingRows,
|
||||||
|
}: UseNewWoWizardControllerArgs) {
|
||||||
|
const draftState = useNewWoWizardDraft(open);
|
||||||
|
|
||||||
|
const { data: locations = [] } = useLocationSites();
|
||||||
|
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
||||||
|
const {
|
||||||
|
data: locationDetail,
|
||||||
|
isLoading: locationDetailLoading,
|
||||||
|
isError: locationDetailError,
|
||||||
|
} = useLocationDetail(draftState.draft.locationId || undefined);
|
||||||
|
const { data: vendors = [] } = useWorkOrderVendors(
|
||||||
|
draftState.draft.pm.trim() || undefined,
|
||||||
|
locationDetail?.zipCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { notifyLocationChanged } = useWizardPocAutofill(
|
||||||
|
open,
|
||||||
|
draftState.draft.locationId,
|
||||||
|
locationDetail,
|
||||||
|
draftState.setDraft,
|
||||||
|
);
|
||||||
|
|
||||||
|
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
||||||
|
draftState.patchDraft(patch);
|
||||||
|
if (patch.locationId !== undefined) {
|
||||||
|
notifyLocationChanged(patch.locationId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
|
||||||
|
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
|
||||||
|
useWizardDuplicateActions({
|
||||||
|
open,
|
||||||
|
draft: draftState.draft,
|
||||||
|
existingRows,
|
||||||
|
createMutation,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDuplicateRow(null);
|
||||||
|
}
|
||||||
|
}, [open, setDuplicateRow]);
|
||||||
|
|
||||||
|
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...draftState,
|
||||||
|
patchDraft,
|
||||||
|
locations,
|
||||||
|
problems,
|
||||||
|
locationDetail,
|
||||||
|
locationDetailLoading,
|
||||||
|
locationDetailError,
|
||||||
|
vendors,
|
||||||
|
duplicateRow,
|
||||||
|
setDuplicateRow,
|
||||||
|
handleDuplicateFound,
|
||||||
|
handleCreate,
|
||||||
|
isCreating,
|
||||||
|
handleClose,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
EMPTY_WIZARD_DRAFT,
|
||||||
|
type WorkOrderWizardDraft,
|
||||||
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
/** Owns the step/draft/service-panel state for the new-work-order wizard, resetting on open. */
|
||||||
|
export function useNewWoWizardDraft(open: boolean) {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
status: "Incomplete",
|
||||||
|
});
|
||||||
|
const [svcOpen, setSvcOpen] = useState(false);
|
||||||
|
const [svcSearch, setSvcSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setStep(1);
|
||||||
|
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
|
||||||
|
setSvcOpen(false);
|
||||||
|
setSvcSearch("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const setField = <K extends keyof WorkOrderWizardDraft>(
|
||||||
|
key: K,
|
||||||
|
value: WorkOrderWizardDraft[K],
|
||||||
|
) => {
|
||||||
|
setDraft((current) => ({ ...current, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
||||||
|
setDraft((current) => ({ ...current, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
step,
|
||||||
|
setStep,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
setField,
|
||||||
|
patchDraft,
|
||||||
|
svcOpen,
|
||||||
|
setSvcOpen,
|
||||||
|
svcSearch,
|
||||||
|
setSvcSearch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
type UseWizardDateFieldStateArgs = {
|
||||||
|
open: boolean;
|
||||||
|
value: string;
|
||||||
|
scheduleEnd: string;
|
||||||
|
allowRange: boolean;
|
||||||
|
allowWeekMode?: boolean;
|
||||||
|
targetWeek: string;
|
||||||
|
schedMode?: WizardSchedMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveInitialMode({
|
||||||
|
allowRange,
|
||||||
|
schedMode,
|
||||||
|
allowWeekMode,
|
||||||
|
value,
|
||||||
|
targetWeek,
|
||||||
|
}: Pick<
|
||||||
|
UseWizardDateFieldStateArgs,
|
||||||
|
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek"
|
||||||
|
>): WizardSchedMode {
|
||||||
|
if (allowRange && schedMode === "range") {
|
||||||
|
return "range";
|
||||||
|
}
|
||||||
|
if (allowWeekMode && !value && targetWeek) {
|
||||||
|
return "week";
|
||||||
|
}
|
||||||
|
return "date";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns the popover-local mode/range selection state for `WizardDateField`. */
|
||||||
|
export function useWizardDateFieldState({
|
||||||
|
open,
|
||||||
|
value,
|
||||||
|
scheduleEnd,
|
||||||
|
allowRange,
|
||||||
|
allowWeekMode,
|
||||||
|
targetWeek,
|
||||||
|
schedMode,
|
||||||
|
}: UseWizardDateFieldStateArgs) {
|
||||||
|
const initialMode = resolveInitialMode({
|
||||||
|
allowRange,
|
||||||
|
schedMode,
|
||||||
|
allowWeekMode,
|
||||||
|
value,
|
||||||
|
targetWeek,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [cellMode, setCellMode] = useState<WizardSchedMode>(initialMode);
|
||||||
|
const [rangeStart, setRangeStart] = useState(value || "");
|
||||||
|
const [rangeEnd, setRangeEnd] = useState(scheduleEnd || "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep draft props as source of truth while open so parent clears
|
||||||
|
// (e.g. Clear schedule) cannot leave stale rangeStart/rangeEnd.
|
||||||
|
setRangeStart(value || "");
|
||||||
|
setRangeEnd(scheduleEnd || "");
|
||||||
|
}, [open, value, scheduleEnd]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setCellMode(initialMode);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reseat mode only when the popover opens
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function handleRangeClick(iso: string) {
|
||||||
|
if (!rangeStart || (rangeStart && rangeEnd)) {
|
||||||
|
setRangeStart(iso);
|
||||||
|
setRangeEnd("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (iso < rangeStart) {
|
||||||
|
setRangeStart(iso);
|
||||||
|
setRangeEnd(rangeStart);
|
||||||
|
} else {
|
||||||
|
setRangeEnd(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
type SetDraft = (updater: (current: WorkOrderWizardDraft) => WorkOrderWizardDraft) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-fills POC name/phone from the fetched location, once per selected location.
|
||||||
|
* Never clobbers fields the dispatcher already typed while detail was loading
|
||||||
|
* (prototype: autofill from site records, then edit if outdated).
|
||||||
|
*/
|
||||||
|
export function useWizardPocAutofill(
|
||||||
|
open: boolean,
|
||||||
|
locationId: string,
|
||||||
|
locationDetail: Location | undefined,
|
||||||
|
setDraft: SetDraft,
|
||||||
|
) {
|
||||||
|
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPocAutoFilledFor("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!locationId || !locationDetail || pocAutoFilledFor === locationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
pocName: current.pocName.trim()
|
||||||
|
? current.pocName
|
||||||
|
: (locationDetail.contact ?? current.pocName),
|
||||||
|
pocPhone: current.pocPhone.trim()
|
||||||
|
? current.pocPhone
|
||||||
|
: (locationDetail.phone ?? current.pocPhone),
|
||||||
|
}));
|
||||||
|
setPocAutoFilledFor(locationId);
|
||||||
|
}, [locationId, locationDetail, pocAutoFilledFor, setDraft]);
|
||||||
|
|
||||||
|
// Always clear the seed guard on a site pick (including re-selecting the
|
||||||
|
// same location). Prototype onPick re-seeds pocName/pocPhone from the site
|
||||||
|
// every time; if we keep the guard keyed by the same id, clearing the fields
|
||||||
|
// leaves them blank because autofill will not re-run.
|
||||||
|
function notifyLocationChanged(_newLocationId: string) {
|
||||||
|
setPocAutoFilledFor("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { notifyLocationChanged };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { CalendarDays, CalendarIcon, CalendarRange, ChevronDown } from "lucide-react";
|
||||||
|
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||||
|
|
||||||
|
type WizardDateFieldTriggerProps = {
|
||||||
|
onAnchor: (element: HTMLElement) => void;
|
||||||
|
open: boolean;
|
||||||
|
label: string;
|
||||||
|
showWeek: boolean;
|
||||||
|
showRange: boolean;
|
||||||
|
hasValue: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DateFieldIcon({ showRange, showWeek }: { showRange: boolean; showWeek: boolean }) {
|
||||||
|
const style = { color: "var(--muted-foreground)" };
|
||||||
|
if (showRange) {
|
||||||
|
return <CalendarRange size={16} className="shrink-0" style={style} />;
|
||||||
|
}
|
||||||
|
if (showWeek) {
|
||||||
|
return <CalendarDays size={16} className="shrink-0" style={style} />;
|
||||||
|
}
|
||||||
|
return <CalendarIcon size={16} className="shrink-0" style={style} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardDateFieldTrigger({
|
||||||
|
onAnchor,
|
||||||
|
open,
|
||||||
|
label,
|
||||||
|
showWeek,
|
||||||
|
showRange,
|
||||||
|
hasValue,
|
||||||
|
}: WizardDateFieldTriggerProps) {
|
||||||
|
return (
|
||||||
|
<MenuAnchorButton
|
||||||
|
onAnchor={onAnchor}
|
||||||
|
open={open}
|
||||||
|
className="flex w-full items-center gap-2 transition-colors"
|
||||||
|
style={{
|
||||||
|
height: 36,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
padding: "0 10px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>
|
||||||
|
<DateFieldIcon showRange={showRange} showWeek={showWeek} />
|
||||||
|
<span
|
||||||
|
className="flex-1 truncate text-left"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 13,
|
||||||
|
color:
|
||||||
|
hasValue || showWeek || showRange ? "var(--foreground)" : "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
</MenuAnchorButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { CalendarDays, CalendarIcon, ChevronDown } from "lucide-react";
|
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||||
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
|
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
|
||||||
import { fmtDate, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { WizardDateFieldTrigger } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field-trigger";
|
||||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
import { WizardDateModeTabs } from "@/app/(protected)/workorders/_components/wizard/wizard-date-mode-tabs";
|
||||||
|
import { WizardDateRangePanel } from "@/app/(protected)/workorders/_components/wizard/wizard-date-range-panel";
|
||||||
|
import { useWizardDateFieldState } from "@/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state";
|
||||||
|
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { fmtDate, fmtDateRange, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
||||||
type WizardDateFieldProps = {
|
type WizardDateFieldProps = {
|
||||||
|
|
@ -14,8 +16,29 @@ type WizardDateFieldProps = {
|
||||||
allowWeekMode?: boolean;
|
allowWeekMode?: boolean;
|
||||||
targetWeek?: string;
|
targetWeek?: string;
|
||||||
onChangeWeek?: (weekStart: string) => void;
|
onChangeWeek?: (weekStart: string) => void;
|
||||||
|
scheduleEnd?: string;
|
||||||
|
schedMode?: WizardSchedMode;
|
||||||
|
onChangeRange?: (start: string, end: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function fieldLabel(args: {
|
||||||
|
showRange: boolean;
|
||||||
|
showWeek: boolean;
|
||||||
|
value: string;
|
||||||
|
scheduleEnd: string;
|
||||||
|
targetWeek: string;
|
||||||
|
placeholder: string;
|
||||||
|
}): string {
|
||||||
|
const { showRange, showWeek, value, scheduleEnd, targetWeek, placeholder } = args;
|
||||||
|
if (showRange) {
|
||||||
|
return fmtDateRange(value, scheduleEnd);
|
||||||
|
}
|
||||||
|
if (showWeek) {
|
||||||
|
return fmtWeekRange(targetWeek);
|
||||||
|
}
|
||||||
|
return value ? fmtDate(value) : placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
export function WizardDateField({
|
export function WizardDateField({
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
|
@ -23,60 +46,44 @@ export function WizardDateField({
|
||||||
allowWeekMode,
|
allowWeekMode,
|
||||||
targetWeek = "",
|
targetWeek = "",
|
||||||
onChangeWeek,
|
onChangeWeek,
|
||||||
|
scheduleEnd = "",
|
||||||
|
schedMode,
|
||||||
|
onChangeRange,
|
||||||
}: WizardDateFieldProps) {
|
}: WizardDateFieldProps) {
|
||||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
const [cellMode, setCellMode] = useState<"week" | "date">(
|
const allowRange = Boolean(allowWeekMode && onChangeRange);
|
||||||
allowWeekMode && !value && targetWeek ? "week" : "date",
|
|
||||||
|
const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
|
||||||
|
{
|
||||||
|
open,
|
||||||
|
value,
|
||||||
|
scheduleEnd,
|
||||||
|
allowRange,
|
||||||
|
allowWeekMode,
|
||||||
|
targetWeek,
|
||||||
|
schedMode,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
|
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
|
||||||
|
const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
|
||||||
|
|
||||||
|
const modes: WizardSchedMode[] = allowRange
|
||||||
|
? ["week", "date", "range"]
|
||||||
|
: allowWeekMode
|
||||||
|
? ["week", "date"]
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MenuAnchorButton
|
<WizardDateFieldTrigger
|
||||||
onAnchor={setAnchor}
|
onAnchor={setAnchor}
|
||||||
open={open}
|
open={open}
|
||||||
className="flex w-full items-center gap-2 transition-colors"
|
label={fieldLabel({ showRange, showWeek, value, scheduleEnd, targetWeek, placeholder })}
|
||||||
style={{
|
showWeek={showWeek}
|
||||||
height: 36,
|
showRange={showRange}
|
||||||
border: "1px solid var(--color-border)",
|
hasValue={Boolean(value)}
|
||||||
borderRadius: 6,
|
/>
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
padding: "0 10px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
|
|
||||||
}}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
>
|
|
||||||
{showWeek ? (
|
|
||||||
<CalendarDays
|
|
||||||
size={16}
|
|
||||||
className="shrink-0"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CalendarIcon
|
|
||||||
size={16}
|
|
||||||
className="shrink-0"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="flex-1 truncate text-left"
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: value || showWeek ? "var(--foreground)" : "var(--muted-foreground)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showWeek ? fmtWeekRange(targetWeek) : value ? fmtDate(value) : placeholder}
|
|
||||||
</span>
|
|
||||||
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
|
||||||
</MenuAnchorButton>
|
|
||||||
|
|
||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -91,35 +98,12 @@ export function WizardDateField({
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Boolean(allowWeekMode) && (
|
<WizardDateModeTabs
|
||||||
<div className="flex items-center gap-1.5" style={{ marginBottom: 10 }}>
|
modes={modes}
|
||||||
{(["week", "date"] as const).map((m) => {
|
cellMode={cellMode}
|
||||||
const activeMode = cellMode === m;
|
allowRange={allowRange}
|
||||||
return (
|
onSelect={setCellMode}
|
||||||
<button
|
/>
|
||||||
key={m}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCellMode(m)}
|
|
||||||
style={{
|
|
||||||
height: 26,
|
|
||||||
padding: "0 12px",
|
|
||||||
borderRadius: 9999,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
fontSize: 11.5,
|
|
||||||
fontWeight: 500,
|
|
||||||
background: activeMode
|
|
||||||
? "var(--color-primary-tint)"
|
|
||||||
: "var(--color-bg-surface)",
|
|
||||||
border: `1px solid ${activeMode ? "var(--color-primary)" : "var(--color-border)"}`,
|
|
||||||
color: activeMode ? "var(--color-primary)" : "var(--color-text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m === "week" ? "Week" : "Specific date"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{allowWeekMode && cellMode === "week" ? (
|
{allowWeekMode && cellMode === "week" ? (
|
||||||
<WeekPicker
|
<WeekPicker
|
||||||
|
|
@ -130,6 +114,16 @@ export function WizardDateField({
|
||||||
close();
|
close();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : allowRange && cellMode === "range" ? (
|
||||||
|
<WizardDateRangePanel
|
||||||
|
rangeStart={rangeStart}
|
||||||
|
rangeEnd={rangeEnd}
|
||||||
|
onRangeClick={handleRangeClick}
|
||||||
|
onDone={(start, end) => {
|
||||||
|
onChangeRange?.(start, end);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MiniCalendar
|
<MiniCalendar
|
||||||
value={value}
|
value={value}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
type WizardDateModeTabsProps = {
|
||||||
|
modes: WizardSchedMode[];
|
||||||
|
cellMode: WizardSchedMode;
|
||||||
|
allowRange: boolean;
|
||||||
|
onSelect: (mode: WizardSchedMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function modeLabel(mode: WizardSchedMode, allowRange: boolean): string {
|
||||||
|
if (mode === "week") {
|
||||||
|
return "Week";
|
||||||
|
}
|
||||||
|
if (mode === "range") {
|
||||||
|
return "Range";
|
||||||
|
}
|
||||||
|
return allowRange ? "Date" : "Specific date";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardDateModeTabs({
|
||||||
|
modes,
|
||||||
|
cellMode,
|
||||||
|
allowRange,
|
||||||
|
onSelect,
|
||||||
|
}: WizardDateModeTabsProps) {
|
||||||
|
if (modes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5" style={{ marginBottom: 10 }}>
|
||||||
|
{modes.map((m) => {
|
||||||
|
const activeMode = cellMode === m;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(m)}
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
padding: "0 12px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: activeMode ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
|
||||||
|
border: `1px solid ${activeMode ? "var(--color-primary)" : "var(--color-border)"}`,
|
||||||
|
color: activeMode ? "var(--color-primary)" : "var(--color-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modeLabel(m, allowRange)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||||
|
import { rangeDayCount } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
type WizardDateRangePanelProps = {
|
||||||
|
rangeStart: string;
|
||||||
|
rangeEnd: string;
|
||||||
|
onRangeClick: (iso: string) => void;
|
||||||
|
onDone: (start: string, end: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WizardDateRangePanel({
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
onRangeClick,
|
||||||
|
onDone,
|
||||||
|
}: WizardDateRangePanelProps) {
|
||||||
|
const canConfirm = Boolean(rangeStart && rangeEnd);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MiniCalendar
|
||||||
|
range
|
||||||
|
value={rangeStart}
|
||||||
|
rangeStart={rangeStart}
|
||||||
|
rangeEnd={rangeEnd}
|
||||||
|
onChange={onRangeClick}
|
||||||
|
/>
|
||||||
|
{Boolean(rangeStart) && (
|
||||||
|
<div
|
||||||
|
className="text-center"
|
||||||
|
style={{ marginTop: 4, fontSize: 11.5, color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const n = rangeDayCount(rangeStart, rangeEnd || rangeStart);
|
||||||
|
return `${n} ${n === 1 ? "day" : "days"} selected`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canConfirm}
|
||||||
|
onClick={() => {
|
||||||
|
if (canConfirm) {
|
||||||
|
onDone(rangeStart, rangeEnd);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full transition-opacity"
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--color-primary)",
|
||||||
|
color: "var(--color-primary-foreground)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: 500,
|
||||||
|
opacity: canConfirm ? 1 : 0.5,
|
||||||
|
pointerEvents: canConfirm ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types";
|
||||||
|
|
||||||
|
type WizardFieldSelectOptionsProps = {
|
||||||
|
search: boolean;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (q: string) => void;
|
||||||
|
options: WizardMenuOption[];
|
||||||
|
value: string;
|
||||||
|
onPick: (key: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WizardFieldSelectOptions({
|
||||||
|
search,
|
||||||
|
searchPlaceholder,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onPick,
|
||||||
|
}: WizardFieldSelectOptionsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{search && (
|
||||||
|
<div className="mb-1 px-1 pb-1.5" style={{ borderBottom: "1px solid var(--color-border)" }}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="w-full rounded px-2 py-1 text-[12px] focus:outline-none"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ maxHeight: 220, overflowY: "auto" }}>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="px-2 py-2 text-center"
|
||||||
|
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
No matches
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(o.key)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
|
||||||
|
<span className="min-w-0 flex-1 text-left">
|
||||||
|
<span
|
||||||
|
className="block truncate text-[12px]"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</span>
|
||||||
|
{Boolean(o.description) && (
|
||||||
|
<span
|
||||||
|
className="mt-0.5 block truncate text-[11px]"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{o.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{o.right != null && <span className="flex shrink-0 items-center">{o.right}</span>}
|
||||||
|
{value === o.key && (
|
||||||
|
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface WizardMenuOption {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
left?: ReactNode;
|
||||||
|
right?: ReactNode;
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { Check, ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { WizardFieldSelectOptions } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-options";
|
||||||
|
import type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types";
|
||||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
||||||
export interface WizardMenuOption {
|
export type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types";
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
left?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WizardFieldSelectProps = {
|
type WizardFieldSelectProps = {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -22,6 +19,19 @@ type WizardFieldSelectProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function filterOptions(options: WizardMenuOption[], query: string): WizardMenuOption[] {
|
||||||
|
if (!query) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
const needle = query.toLowerCase();
|
||||||
|
return options.filter(
|
||||||
|
(o) =>
|
||||||
|
o.label.toLowerCase().includes(needle) ||
|
||||||
|
o.key.toLowerCase().includes(needle) ||
|
||||||
|
(o.description?.toLowerCase().includes(needle) ?? false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WizardFieldSelect({
|
export function WizardFieldSelect({
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
|
@ -35,13 +45,7 @@ export function WizardFieldSelect({
|
||||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const selected = options.find((o) => o.key === value);
|
const selected = options.find((o) => o.key === value);
|
||||||
const filtered = q
|
const filtered = filterOptions(options, q);
|
||||||
? options.filter(
|
|
||||||
(o) =>
|
|
||||||
o.label.toLowerCase().includes(q.toLowerCase()) ||
|
|
||||||
o.key.toLowerCase().includes(q.toLowerCase()),
|
|
||||||
)
|
|
||||||
: options;
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
close();
|
close();
|
||||||
|
|
@ -74,9 +78,8 @@ export function WizardFieldSelect({
|
||||||
}}
|
}}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
>
|
>
|
||||||
{icon != null && <span className="flex shrink-0 items-center">{icon}</span>}
|
{(selected?.left ?? icon) != null && (
|
||||||
{icon == null && selected?.left != null && (
|
<span className="flex shrink-0 items-center">{selected?.left ?? icon}</span>
|
||||||
<span className="flex shrink-0 items-center">{selected.left}</span>
|
|
||||||
)}
|
)}
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<span
|
<span
|
||||||
|
|
@ -110,59 +113,18 @@ export function WizardFieldSelect({
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{search && (
|
<WizardFieldSelectOptions
|
||||||
<div
|
search={search}
|
||||||
className="mb-1 px-1 pb-1.5"
|
searchPlaceholder={searchPlaceholder}
|
||||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
query={q}
|
||||||
>
|
onQueryChange={setQ}
|
||||||
<input
|
options={filtered}
|
||||||
autoFocus
|
value={value}
|
||||||
value={q}
|
onPick={(key) => {
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onPick(key);
|
||||||
placeholder={searchPlaceholder}
|
handleClose();
|
||||||
className="w-full rounded px-2 py-1 text-[12px] focus:outline-none"
|
}}
|
||||||
style={{
|
/>
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ maxHeight: 220, overflowY: "auto" }}>
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
className="px-2 py-2 text-center"
|
|
||||||
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
No matches
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
filtered.map((o) => (
|
|
||||||
<button
|
|
||||||
key={o.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onPick(o.key);
|
|
||||||
handleClose();
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
|
||||||
>
|
|
||||||
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
|
|
||||||
<span
|
|
||||||
className="flex-1 truncate text-left text-[12px]"
|
|
||||||
style={{ color: "var(--color-text-primary)" }}
|
|
||||||
>
|
|
||||||
{o.label}
|
|
||||||
</span>
|
|
||||||
{value === o.key && (
|
|
||||||
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import {
|
||||||
|
fmtDate,
|
||||||
|
fmtDateRange,
|
||||||
|
fmtWeekRange,
|
||||||
|
formatApptRange,
|
||||||
|
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
function scheduleOnLabel(draft: WorkOrderWizardDraft): string {
|
||||||
|
if (draft.schedMode === "range" && draft.scheduledOn && draft.scheduleEnd) {
|
||||||
|
return fmtDateRange(draft.scheduledOn, draft.scheduleEnd);
|
||||||
|
}
|
||||||
|
if (!draft.scheduledOn) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
if (draft.scheduleWeekOnly) {
|
||||||
|
return fmtWeekRange(draft.targetWeek || draft.scheduledOn);
|
||||||
|
}
|
||||||
|
return fmtDate(draft.scheduledOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pocLabel(draft: WorkOrderWizardDraft): string {
|
||||||
|
if (!draft.pocName) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return draft.pocPhone ? `${draft.pocName} · ${draft.pocPhone}` : draft.pocName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the label/value rows for the wizard sidebar's live summary card. */
|
||||||
|
export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [string, string][] {
|
||||||
|
return [
|
||||||
|
["WO #", draft.woNumber ? `#${draft.woNumber}` : "—"],
|
||||||
|
["Type", draft.type || "—"],
|
||||||
|
["Schedule on", scheduleOnLabel(draft)],
|
||||||
|
["Due date", fmtDate(draft.dueDate) || "—"],
|
||||||
|
["Assigned to", draft.assigneeName || "—"],
|
||||||
|
["Site", draft.siteCode || draft.locationName || "—"],
|
||||||
|
["POC", pocLabel(draft)],
|
||||||
|
["Service", draft.pm || "—"],
|
||||||
|
["Technician", draft.vendorName || "—"],
|
||||||
|
["Company", draft.vendorName || "—"],
|
||||||
|
["Appt time", draft.apptStart ? formatApptRange(draft.apptStart, draft.apptEnd) : "—"],
|
||||||
|
["Status", draft.status || "Incomplete"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import {
|
||||||
|
WIZARD_STATUS_DOTS,
|
||||||
|
type WizardWOStatus,
|
||||||
|
type WorkOrderWizardDraft,
|
||||||
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { buildWizardSidebarSummaryRows } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows";
|
||||||
|
import { ColorDot } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
|
||||||
|
export function WizardSidebarSummary({ draft }: { draft: WorkOrderWizardDraft }) {
|
||||||
|
const summary = buildWizardSidebarSummaryRows(draft);
|
||||||
|
const statusLabel = draft.status || "Incomplete";
|
||||||
|
const statusValue = statusLabel as WizardWOStatus;
|
||||||
|
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: "auto", paddingTop: 44 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This work order
|
||||||
|
</div>
|
||||||
|
{summary.map(([label, val], i) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
style={{
|
||||||
|
padding: "4px 0",
|
||||||
|
borderBottom: i < summary.length - 1 ? "1px solid var(--color-border)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{label === "Status" ? (
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||||
|
<ColorDot color={statusDotColor} />
|
||||||
|
<span
|
||||||
|
className="truncate text-right"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="truncate text-right"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{val}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
import { ClipboardList } from "lucide-react";
|
import { ClipboardList } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
WIZARD_STATUS_DOTS,
|
|
||||||
WIZARD_STEPS,
|
WIZARD_STEPS,
|
||||||
type WizardWOStatus,
|
|
||||||
type WorkOrderWizardDraft,
|
type WorkOrderWizardDraft,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import {
|
import { StepCircle } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
fmtDate,
|
import { WizardSidebarSummary } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary";
|
||||||
fmtWeekRange,
|
|
||||||
formatApptRange,
|
|
||||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
|
||||||
import {
|
|
||||||
ColorDot,
|
|
||||||
StepCircle,
|
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
|
||||||
|
|
||||||
type WizardSidebarProps = {
|
type WizardSidebarProps = {
|
||||||
step: number;
|
step: number;
|
||||||
|
|
@ -120,106 +111,3 @@ export function WizardSidebar({ step, draft }: WizardSidebarProps) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WizardSidebarSummary({ draft }: { draft: WorkOrderWizardDraft }) {
|
|
||||||
const summary: [string, string][] = [
|
|
||||||
["WO #", draft.woNumber ? `#${draft.woNumber}` : "—"],
|
|
||||||
["Type", draft.type || "—"],
|
|
||||||
[
|
|
||||||
"Schedule on",
|
|
||||||
draft.scheduledOn
|
|
||||||
? draft.scheduleWeekOnly
|
|
||||||
? fmtWeekRange(draft.scheduledOn)
|
|
||||||
: fmtDate(draft.scheduledOn)
|
|
||||||
: "—",
|
|
||||||
],
|
|
||||||
["Due date", fmtDate(draft.dueDate) || "—"],
|
|
||||||
["Assigned to", draft.assigneeName || "—"],
|
|
||||||
["Site", draft.locationName || "—"],
|
|
||||||
[
|
|
||||||
"POC",
|
|
||||||
draft.pocName
|
|
||||||
? draft.pocPhone
|
|
||||||
? `${draft.pocName} · ${draft.pocPhone}`
|
|
||||||
: draft.pocName
|
|
||||||
: "—",
|
|
||||||
],
|
|
||||||
["Service", draft.pm || "—"],
|
|
||||||
["Technician", draft.vendorName || "—"],
|
|
||||||
["Company", draft.vendorName || "—"],
|
|
||||||
["Appt time", draft.apptStart ? formatApptRange(draft.apptStart, draft.apptEnd) : "—"],
|
|
||||||
["Status", draft.status || "Incomplete"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusLabel = draft.status || "Incomplete";
|
|
||||||
const statusValue = statusLabel as WizardWOStatus;
|
|
||||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: "auto", paddingTop: 44 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.08em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
This work order
|
|
||||||
</div>
|
|
||||||
{summary.map(([label, val], i) => (
|
|
||||||
<div
|
|
||||||
key={label}
|
|
||||||
className="flex items-center justify-between gap-2"
|
|
||||||
style={{
|
|
||||||
padding: "4px 0",
|
|
||||||
borderBottom: i < summary.length - 1 ? "1px solid var(--color-border)" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="shrink-0"
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 400,
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
{label === "Status" ? (
|
|
||||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
|
||||||
<ColorDot color={statusDotColor} />
|
|
||||||
<span
|
|
||||||
className="truncate text-right"
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--foreground)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="truncate text-right"
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--foreground)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{val}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { WIZARD_STEPS } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
export function WizardStepHeader({ step }: { step: number }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Step {step} of {WIZARD_STEPS.length}
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
variant="title"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 20,
|
||||||
|
color: "var(--foreground)",
|
||||||
|
marginTop: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WIZARD_STEPS[step - 1].title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="description"
|
||||||
|
sx={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
marginTop: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WIZARD_STEPS[step - 1].subtitle}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
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";
|
||||||
|
|
||||||
|
type WizardStepLocationPocFieldsProps = {
|
||||||
|
draft: WorkOrderWizardDraft;
|
||||||
|
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocationPocFieldsProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<WizSubLabel>Point of contact</WizSubLabel>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="description"
|
||||||
|
sx={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}
|
||||||
|
>
|
||||||
|
Auto-filled from records — edit if outdated
|
||||||
|
</Text>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
value={draft.pocName}
|
||||||
|
onChange={(e) => onUpdate("pocName", e.target.value)}
|
||||||
|
placeholder="POC name"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draft.pocPhone}
|
||||||
|
onChange={(e) => onUpdate("pocPhone", e.target.value)}
|
||||||
|
placeholder="POC phone"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={draft.pocNotes}
|
||||||
|
onChange={(e) => onUpdate("pocNotes", e.target.value)}
|
||||||
|
placeholder="Notes…"
|
||||||
|
rows={2}
|
||||||
|
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { Droplets, Plus } from "lucide-react";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { getPmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
|
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
|
||||||
|
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
|
||||||
|
type ServiceOption = { id: string | number; value: string };
|
||||||
|
|
||||||
|
type WizardStepLocationServiceSelectProps = {
|
||||||
|
draft: WorkOrderWizardDraft;
|
||||||
|
services: ServiceOption[];
|
||||||
|
svcOpen: boolean;
|
||||||
|
onSvcOpenChange: (open: boolean) => void;
|
||||||
|
svcSearch: string;
|
||||||
|
onSvcSearchChange: (q: string) => void;
|
||||||
|
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||||
|
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WizardStepLocationServiceSelect({
|
||||||
|
draft,
|
||||||
|
services,
|
||||||
|
svcOpen,
|
||||||
|
onSvcOpenChange,
|
||||||
|
svcSearch,
|
||||||
|
onSvcSearchChange,
|
||||||
|
onUpdate,
|
||||||
|
onPatch,
|
||||||
|
}: WizardStepLocationServiceSelectProps) {
|
||||||
|
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
|
||||||
|
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {};
|
||||||
|
|
||||||
|
const selectSvc = (p: string) => {
|
||||||
|
onPatch({
|
||||||
|
pm: p,
|
||||||
|
extraServices: [],
|
||||||
|
...clearVendorIfPrimaryChanged(p),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<WizLabel optional>Service</WizLabel>
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={draft.pm}
|
||||||
|
placeholder="Select service…"
|
||||||
|
icon={
|
||||||
|
<Droplets size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search PM type…"
|
||||||
|
options={services.map((s) => {
|
||||||
|
const Icon = getPmTypeIcon(s.value);
|
||||||
|
return {
|
||||||
|
key: s.value,
|
||||||
|
label: s.value,
|
||||||
|
left: (
|
||||||
|
<Icon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
onPick={selectSvc}
|
||||||
|
/>
|
||||||
|
{!svcOpen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSvcOpenChange(true)}
|
||||||
|
className="transition-colors"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--color-primary)",
|
||||||
|
padding: "6px 0 0",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={12} /> Service & Notes
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="mt-2 rounded-lg p-3"
|
||||||
|
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<WizardServiceNotesFields
|
||||||
|
services={services}
|
||||||
|
sel={draft.pm ? [draft.pm] : []}
|
||||||
|
onToggle={selectSvc}
|
||||||
|
note={draft.pmNote}
|
||||||
|
setNote={(v) => onUpdate("pmNote", v)}
|
||||||
|
search={svcSearch}
|
||||||
|
setSearch={onSvcSearchChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardStepLocationPocFields } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields";
|
||||||
import {
|
import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select";
|
||||||
DLG_INPUT_CLS,
|
import { WizardStepLocationSiteDetails } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details";
|
||||||
DLG_INPUT_STYLE,
|
import { WizardStepLocationSiteSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select";
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
|
||||||
import {
|
|
||||||
Detail,
|
|
||||||
WizLabel,
|
|
||||||
WizSubLabel,
|
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
|
||||||
|
|
||||||
type ServiceOption = { id: string | number; value: string };
|
type ServiceOption = { id: string | number; value: string };
|
||||||
|
|
||||||
|
|
@ -20,151 +13,64 @@ type WizardStepLocationServiceProps = {
|
||||||
draft: WorkOrderWizardDraft;
|
draft: WorkOrderWizardDraft;
|
||||||
locations: LocationOption[];
|
locations: LocationOption[];
|
||||||
locationDetail: Location | undefined;
|
locationDetail: Location | undefined;
|
||||||
|
locationDetailLoading?: boolean;
|
||||||
|
locationDetailError?: boolean;
|
||||||
services: ServiceOption[];
|
services: ServiceOption[];
|
||||||
|
svcOpen: boolean;
|
||||||
|
onSvcOpenChange: (open: boolean) => void;
|
||||||
|
svcSearch: string;
|
||||||
|
onSvcSearchChange: (q: string) => void;
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildMapsUrl(location: Location): string {
|
|
||||||
const parts = [location.address, location.city, location.state, location.zipCode]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WizardStepLocationService({
|
export function WizardStepLocationService({
|
||||||
draft,
|
draft,
|
||||||
locations,
|
locations,
|
||||||
locationDetail,
|
locationDetail,
|
||||||
|
locationDetailLoading,
|
||||||
|
locationDetailError,
|
||||||
services,
|
services,
|
||||||
|
svcOpen,
|
||||||
|
onSvcOpenChange,
|
||||||
|
svcSearch,
|
||||||
|
onSvcSearchChange,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onPatch,
|
onPatch,
|
||||||
}: WizardStepLocationServiceProps) {
|
}: WizardStepLocationServiceProps) {
|
||||||
|
const selectedLoc = locations.find((l) => String(l.id) === draft.locationId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<WizardStepLocationSiteSelect
|
||||||
<WizLabel required>Site</WizLabel>
|
locationId={draft.locationId}
|
||||||
<WizardFieldSelect
|
locations={locations}
|
||||||
value={draft.locationId}
|
onPatch={onPatch}
|
||||||
placeholder="Select site…"
|
/>
|
||||||
icon={
|
|
||||||
<MapPin size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
|
||||||
}
|
|
||||||
searchPlaceholder="Search site code or city…"
|
|
||||||
options={locations.map((loc) => ({
|
|
||||||
key: String(loc.id),
|
|
||||||
label: loc.name,
|
|
||||||
}))}
|
|
||||||
onPick={(id) => {
|
|
||||||
const loc = locations.find((l) => String(l.id) === id);
|
|
||||||
onPatch({
|
|
||||||
locationId: id,
|
|
||||||
locationName: loc?.name ?? "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!draft.locationId && (
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
variant="description"
|
|
||||||
className="mt-1.5 flex items-center gap-1"
|
|
||||||
sx={{ fontSize: 11, color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
<Info size={12} className="shrink-0" /> Site is required to continue.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{locationDetail != null && draft.locationId !== "" && (
|
{draft.locationId !== "" && (
|
||||||
<>
|
<>
|
||||||
<div
|
<WizardStepLocationSiteDetails
|
||||||
className="space-y-1.5 rounded-lg p-3"
|
selectedLoc={selectedLoc}
|
||||||
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
|
locationName={draft.locationName}
|
||||||
>
|
locationDetail={locationDetail}
|
||||||
<Detail
|
locationDetailLoading={locationDetailLoading}
|
||||||
icon={MapPin}
|
locationDetailError={locationDetailError}
|
||||||
text={
|
/>
|
||||||
[
|
<WizardStepLocationPocFields draft={draft} onUpdate={onUpdate} />
|
||||||
locationDetail.address,
|
|
||||||
locationDetail.city,
|
|
||||||
locationDetail.state,
|
|
||||||
locationDetail.zipCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ") || "—"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{Boolean(locationDetail.phone) && (
|
|
||||||
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
|
|
||||||
)}
|
|
||||||
{Boolean(locationDetail.contactEmail) && (
|
|
||||||
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
|
||||||
)}
|
|
||||||
{Boolean(locationDetail.address) && (
|
|
||||||
<a
|
|
||||||
href={buildMapsUrl(locationDetail)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
|
||||||
style={{ color: "var(--color-primary)" }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={12} /> Open in Google Maps
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<WizSubLabel>Point of contact</WizSubLabel>
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
variant="description"
|
|
||||||
sx={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}
|
|
||||||
>
|
|
||||||
Auto-filled from records — edit if outdated
|
|
||||||
</Text>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<input
|
|
||||||
value={draft.pocName}
|
|
||||||
onChange={(e) => onUpdate("pocName", e.target.value)}
|
|
||||||
placeholder="POC name"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={draft.pocPhone}
|
|
||||||
onChange={(e) => onUpdate("pocPhone", e.target.value)}
|
|
||||||
placeholder="POC phone"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={draft.pocNotes}
|
|
||||||
onChange={(e) => onUpdate("pocNotes", e.target.value)}
|
|
||||||
placeholder="Notes…"
|
|
||||||
rows={2}
|
|
||||||
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<WizardStepLocationServiceSelect
|
||||||
<WizLabel optional>Service</WizLabel>
|
draft={draft}
|
||||||
<WizardFieldSelect
|
services={services}
|
||||||
value={draft.pm}
|
svcOpen={svcOpen}
|
||||||
placeholder="Select service…"
|
onSvcOpenChange={onSvcOpenChange}
|
||||||
icon={
|
svcSearch={svcSearch}
|
||||||
<MapPin size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
onSvcSearchChange={onSvcSearchChange}
|
||||||
}
|
onUpdate={onUpdate}
|
||||||
searchPlaceholder="Search PM type…"
|
onPatch={onPatch}
|
||||||
options={services.map((s) => ({
|
/>
|
||||||
key: s.value,
|
|
||||||
label: s.value,
|
|
||||||
}))}
|
|
||||||
onPick={(p) => onUpdate("pm", p)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!draft.locationId && !draft.pm && (
|
{!draft.locationId && !draft.pm && (
|
||||||
<Text
|
<Text
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||||
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
|
||||||
|
function buildMapsUrl(location: Location): string {
|
||||||
|
const query = formatLocationAddressPreview(location);
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WizardStepLocationSiteDetailsProps = {
|
||||||
|
selectedLoc: LocationOption | undefined;
|
||||||
|
locationName: string;
|
||||||
|
locationDetail: Location | undefined;
|
||||||
|
locationDetailLoading?: boolean;
|
||||||
|
locationDetailError?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SiteDetailsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5" aria-busy="true" role="status" aria-label="Loading site details">
|
||||||
|
<span
|
||||||
|
className="block h-3 w-3/4 animate-pulse rounded"
|
||||||
|
style={{ background: "var(--color-bg-muted)", width: "75%", height: 12 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="block h-3 w-1/2 animate-pulse rounded"
|
||||||
|
style={{ background: "var(--color-bg-muted)", width: "50%", height: 12 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardStepLocationSiteDetails({
|
||||||
|
selectedLoc,
|
||||||
|
locationName,
|
||||||
|
locationDetail,
|
||||||
|
locationDetailLoading,
|
||||||
|
locationDetailError,
|
||||||
|
}: WizardStepLocationSiteDetailsProps) {
|
||||||
|
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
|
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
|
||||||
|
>
|
||||||
|
{locationDetail ? (
|
||||||
|
<>
|
||||||
|
<Detail icon={MapPin} text={addressPreview || selectedLoc?.name || locationName || "—"} />
|
||||||
|
{Boolean(locationDetail.phone) && (
|
||||||
|
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
|
||||||
|
)}
|
||||||
|
{Boolean(locationDetail.contactEmail) && (
|
||||||
|
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
||||||
|
)}
|
||||||
|
{Boolean(addressPreview) && (
|
||||||
|
<a
|
||||||
|
href={buildMapsUrl(locationDetail)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} /> Open in Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Detail icon={MapPin} text={selectedLoc?.name || locationName || "—"} />
|
||||||
|
{Boolean(locationDetailLoading) && <SiteDetailsSkeleton />}
|
||||||
|
{Boolean(locationDetailError) && (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="description"
|
||||||
|
sx={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Could not load full site details. City and site code above are still available.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Info, MapPin } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
|
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
|
||||||
|
type WizardStepLocationSiteSelectProps = {
|
||||||
|
locationId: string;
|
||||||
|
locations: LocationOption[];
|
||||||
|
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WizardStepLocationSiteSelect({
|
||||||
|
locationId,
|
||||||
|
locations,
|
||||||
|
onPatch,
|
||||||
|
}: WizardStepLocationSiteSelectProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<WizLabel required>Site</WizLabel>
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={locationId}
|
||||||
|
placeholder="Select site…"
|
||||||
|
icon={
|
||||||
|
<MapPin size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search site code or city…"
|
||||||
|
options={locations.map((loc) => ({
|
||||||
|
key: String(loc.id),
|
||||||
|
label: loc.name,
|
||||||
|
}))}
|
||||||
|
onPick={(id) => {
|
||||||
|
const loc = locations.find((l) => String(l.id) === id);
|
||||||
|
// Switching site invalidates the previous POC — clear so autofill can
|
||||||
|
// seed from the new location without clobbering mid-load edits later.
|
||||||
|
onPatch({
|
||||||
|
locationId: id,
|
||||||
|
locationName: loc?.name ?? "",
|
||||||
|
siteCode: loc ? resolveSiteCode(loc) : "",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!locationId && (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="description"
|
||||||
|
className="mt-1.5 flex items-center gap-1"
|
||||||
|
sx={{ fontSize: 11, color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<Info size={12} className="shrink-0" /> Site is required to continue.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ 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, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
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";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
|
|
@ -127,6 +128,8 @@ export function WizardStepTypeSchedule({
|
||||||
onPatch,
|
onPatch,
|
||||||
onDuplicateWoNumber,
|
onDuplicateWoNumber,
|
||||||
}: WizardStepTypeScheduleProps) {
|
}: WizardStepTypeScheduleProps) {
|
||||||
|
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -138,10 +141,11 @@ export function WizardStepTypeSchedule({
|
||||||
<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)}
|
onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))}
|
||||||
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
|
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
|
||||||
placeholder="e.g. 24818"
|
placeholder="e.g. 12345678901"
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
className={DLG_INPUT_CLS}
|
className={DLG_INPUT_CLS}
|
||||||
style={DLG_INPUT_STYLE}
|
style={DLG_INPUT_STYLE}
|
||||||
|
|
@ -154,13 +158,17 @@ export function WizardStepTypeSchedule({
|
||||||
value={draft.assigneeId}
|
value={draft.assigneeId}
|
||||||
placeholder="Select…"
|
placeholder="Select…"
|
||||||
icon={
|
icon={
|
||||||
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
selectedUser ? (
|
||||||
|
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />
|
||||||
|
) : (
|
||||||
|
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
searchPlaceholder="Search dispatcher…"
|
searchPlaceholder="Search dispatcher…"
|
||||||
options={users.map((u) => ({
|
options={users.map((u) => ({
|
||||||
key: String(u.id),
|
key: String(u.id),
|
||||||
label: u.name,
|
label: u.name,
|
||||||
left: <ColorDot color="var(--color-primary)" />,
|
left: <ColorDot color={getDispatcherAvatarColor(u)} />,
|
||||||
}))}
|
}))}
|
||||||
onPick={(id) => {
|
onPick={(id) => {
|
||||||
const user = users.find((u) => String(u.id) === id);
|
const user = users.find((u) => String(u.id) === id);
|
||||||
|
|
@ -177,8 +185,15 @@ export function WizardStepTypeSchedule({
|
||||||
<WizLabelRow
|
<WizLabelRow
|
||||||
optional
|
optional
|
||||||
onClear={
|
onClear={
|
||||||
draft.scheduledOn !== "" || draft.scheduleWeekOnly
|
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
|
||||||
? () => onPatch({ scheduledOn: "", scheduleWeekOnly: false })
|
? () =>
|
||||||
|
onPatch({
|
||||||
|
scheduledOn: "",
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
scheduleEnd: "",
|
||||||
|
schedMode: undefined,
|
||||||
|
targetWeek: "",
|
||||||
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -186,11 +201,38 @@ export function WizardStepTypeSchedule({
|
||||||
</WizLabelRow>
|
</WizLabelRow>
|
||||||
<WizardDateField
|
<WizardDateField
|
||||||
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
|
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
|
||||||
targetWeek={draft.scheduleWeekOnly ? draft.scheduledOn : ""}
|
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
|
||||||
placeholder="Pick a date"
|
placeholder="Pick a date"
|
||||||
allowWeekMode
|
allowWeekMode
|
||||||
onChange={(iso) => onPatch({ scheduledOn: iso, scheduleWeekOnly: false })}
|
scheduleEnd={draft.scheduleEnd}
|
||||||
onChangeWeek={(w) => onPatch({ scheduledOn: w, scheduleWeekOnly: Boolean(w) })}
|
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import { Clock, ExternalLink, Mail, Wrench } from "lucide-react";
|
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
import {
|
import {
|
||||||
ALL_WIZARD_STATUSES,
|
|
||||||
TIME_SLOTS,
|
TIME_SLOTS,
|
||||||
WIZARD_STATUS_DOTS,
|
|
||||||
type WizardWOStatus,
|
|
||||||
type WorkOrderWizardDraft,
|
type WorkOrderWizardDraft,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import {
|
||||||
|
formatVendorDistance,
|
||||||
|
formatVendorTechnicianLabel,
|
||||||
|
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import {
|
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 {
|
import {
|
||||||
ColorDot,
|
|
||||||
Detail,
|
Detail,
|
||||||
WizLabel,
|
WizLabel,
|
||||||
WizLabelRow,
|
WizLabelRow,
|
||||||
|
|
@ -33,11 +33,13 @@ function WizardVendorDetail({
|
||||||
vendor,
|
vendor,
|
||||||
techPhone,
|
techPhone,
|
||||||
vendorNotes,
|
vendorNotes,
|
||||||
|
distance,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
vendor: VendorDropdownItem;
|
vendor: VendorDropdownItem;
|
||||||
techPhone: string;
|
techPhone: string;
|
||||||
vendorNotes: string;
|
vendorNotes: string;
|
||||||
|
distance: string;
|
||||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -61,9 +63,20 @@ function WizardVendorDetail({
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
<div className="flex items-start justify-between gap-2">
|
||||||
{vendor.companyName || "—"}
|
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
||||||
|
{vendor.companyName || "—"}
|
||||||
|
</div>
|
||||||
|
{Boolean(distance) && (
|
||||||
|
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{distance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{Boolean(vendor.contactName) && <Detail icon={User} text={vendor.contactName} />}
|
||||||
|
{Boolean(vendor.tradeSpecialties) && (
|
||||||
|
<Detail icon={Wrench} text={vendor.tradeSpecialties} />
|
||||||
|
)}
|
||||||
{Boolean(vendor.address) && <Detail icon={Mail} text={vendor.address} />}
|
{Boolean(vendor.address) && <Detail icon={Mail} text={vendor.address} />}
|
||||||
{Boolean(vendor.address) && (
|
{Boolean(vendor.address) && (
|
||||||
<a
|
<a
|
||||||
|
|
@ -100,8 +113,7 @@ export function WizardStepVendorTime({
|
||||||
onPatch,
|
onPatch,
|
||||||
}: WizardStepVendorTimeProps) {
|
}: WizardStepVendorTimeProps) {
|
||||||
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
|
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
|
||||||
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
|
||||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|
@ -133,7 +145,9 @@ export function WizardStepVendorTime({
|
||||||
color: "var(--color-text-secondary)",
|
color: "var(--color-text-secondary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedVendor.companyName.slice(0, 2).toUpperCase()}
|
{(selectedVendor.contactName || selectedVendor.companyName)
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
|
@ -142,7 +156,7 @@ export function WizardStepVendorTime({
|
||||||
searchPlaceholder="Search technician…"
|
searchPlaceholder="Search technician…"
|
||||||
options={vendors.map((v) => ({
|
options={vendors.map((v) => ({
|
||||||
key: String(v.id),
|
key: String(v.id),
|
||||||
label: v.companyName,
|
label: formatVendorTechnicianLabel(v),
|
||||||
}))}
|
}))}
|
||||||
onPick={(id) => {
|
onPick={(id) => {
|
||||||
const vendor = vendors.find((v) => String(v.id) === id);
|
const vendor = vendors.find((v) => String(v.id) === id);
|
||||||
|
|
@ -160,6 +174,7 @@ export function WizardStepVendorTime({
|
||||||
vendor={selectedVendor}
|
vendor={selectedVendor}
|
||||||
techPhone={draft.techPhone}
|
techPhone={draft.techPhone}
|
||||||
vendorNotes={draft.vendorNotes}
|
vendorNotes={draft.vendorNotes}
|
||||||
|
distance={selectedDistance}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -213,22 +228,6 @@ export function WizardStepVendorTime({
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<WizLabel>Status</WizLabel>
|
|
||||||
<WizardFieldSelect
|
|
||||||
value={statusValue}
|
|
||||||
placeholder="Select status…"
|
|
||||||
search={false}
|
|
||||||
icon={<ColorDot color={statusDotColor} />}
|
|
||||||
options={ALL_WIZARD_STATUSES.map((s) => ({
|
|
||||||
key: s,
|
|
||||||
label: s,
|
|
||||||
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
|
|
||||||
}))}
|
|
||||||
onPick={(s) => onUpdate("status", s as WizardWOStatus)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
import { useForm, useWatch } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router";
|
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router";
|
||||||
import { Box, Breadcrumbs, Button, CircularProgress, Link, Paper, Stack } from "@mui/material";
|
import { Box, Breadcrumbs, Button, Link, Paper, Stack } from "@mui/material";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||||
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
|
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||||
|
|
@ -28,6 +29,7 @@ import {
|
||||||
WorkOrderDetailsFields,
|
WorkOrderDetailsFields,
|
||||||
} from "@/app/(protected)/workorders/_components/work-order-fields";
|
} from "@/app/(protected)/workorders/_components/work-order-fields";
|
||||||
import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section";
|
import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section";
|
||||||
|
import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton";
|
||||||
|
|
||||||
type ScheduleBounds = {
|
type ScheduleBounds = {
|
||||||
kind: "valid" | "invalid" | "open";
|
kind: "valid" | "invalid" | "open";
|
||||||
|
|
@ -125,7 +127,12 @@ function useWorkOrderFormState() {
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
const assignVendorRequested = searchParams.get("assignVendor") === "1";
|
const assignVendorRequested = searchParams.get("assignVendor") === "1";
|
||||||
|
|
||||||
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
|
const {
|
||||||
|
data: detail,
|
||||||
|
isLoading: isLoadingDetail,
|
||||||
|
error: detailError,
|
||||||
|
refetch: refetchDetail,
|
||||||
|
} = useWorkOrderDetail(id);
|
||||||
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
|
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
|
||||||
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
|
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
|
||||||
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
||||||
|
|
@ -225,6 +232,7 @@ function useWorkOrderFormState() {
|
||||||
detail,
|
detail,
|
||||||
isLoading,
|
isLoading,
|
||||||
detailError,
|
detailError,
|
||||||
|
refetchDetail,
|
||||||
isSaving: saveMutation.isPending,
|
isSaving: saveMutation.isPending,
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
|
|
@ -264,6 +272,7 @@ export default function WorkOrderFormPage() {
|
||||||
detail,
|
detail,
|
||||||
isLoading,
|
isLoading,
|
||||||
detailError,
|
detailError,
|
||||||
|
refetchDetail,
|
||||||
isSaving,
|
isSaving,
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
|
|
@ -296,17 +305,16 @@ export default function WorkOrderFormPage() {
|
||||||
} = useWorkOrderFormState();
|
} = useWorkOrderFormState();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <DetailPageSkeleton variant="form" />;
|
||||||
<Box className="flex justify-center p-12">
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailError) {
|
if (detailError) {
|
||||||
return (
|
return (
|
||||||
<Box className="p-4">
|
<Box className="p-4">
|
||||||
<Text variant="error">Unable to load this work order. Please try again.</Text>
|
<QueryErrorPanel
|
||||||
|
message={detailError instanceof Error ? detailError.message : "Failed to load work order"}
|
||||||
|
onRetry={() => void refetchDetail()}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,11 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/wor
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
/** Mutates `payload` with `dueDate` and day/week schedule fields (range mode has no schedule fields). */
|
/**
|
||||||
|
* Mutates `payload` with `dueDate` and schedule fields.
|
||||||
|
* Range mode lands on the board using the range start as `scheduledDate`
|
||||||
|
* (prototype `hasScheduleDate`: a range start qualifies as scheduled).
|
||||||
|
*/
|
||||||
export function assignWizardScheduleFields(
|
export function assignWizardScheduleFields(
|
||||||
payload: WorkOrderBoardCreatePayload,
|
payload: WorkOrderBoardCreatePayload,
|
||||||
draft: WorkOrderWizardDraft,
|
draft: WorkOrderWizardDraft,
|
||||||
|
|
@ -21,7 +25,7 @@ export function assignWizardScheduleFields(
|
||||||
payload.scheduleWeekOnly = true;
|
payload.scheduleWeekOnly = true;
|
||||||
payload.targetWeek = week;
|
payload.targetWeek = week;
|
||||||
}
|
}
|
||||||
} else if (!isRange && draft.scheduledOn) {
|
} else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) {
|
||||||
payload.scheduledDate = draft.scheduledOn;
|
payload.scheduledDate = draft.scheduledOn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,42 +7,7 @@ function trimOrUndefined(value: string | undefined): string | undefined {
|
||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExtraServices(
|
/** Mutates `payload` with primary service, service notes, POC, tech phone, and vendor notes. */
|
||||||
primary: string | undefined,
|
|
||||||
extras: string[],
|
|
||||||
): string[] | undefined {
|
|
||||||
if (extras.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
const primaryKey = primary?.toLowerCase() ?? "";
|
|
||||||
|
|
||||||
const out: string[] = [];
|
|
||||||
|
|
||||||
for (const raw of extras) {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
|
|
||||||
if (!trimmed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = trimmed.toLowerCase();
|
|
||||||
|
|
||||||
if (key === primaryKey || seen.has(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.add(key);
|
|
||||||
|
|
||||||
out.push(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.length > 0 ? out : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mutates `payload` with primary/extra services, service notes, POC, tech phone, and vendor notes. */
|
|
||||||
export function assignWizardServicePocFields(
|
export function assignWizardServicePocFields(
|
||||||
payload: WorkOrderBoardCreatePayload,
|
payload: WorkOrderBoardCreatePayload,
|
||||||
draft: WorkOrderWizardDraft,
|
draft: WorkOrderWizardDraft,
|
||||||
|
|
@ -53,12 +18,6 @@ export function assignWizardServicePocFields(
|
||||||
payload.primaryService = primaryService;
|
payload.primaryService = primaryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraServices = normalizeExtraServices(primaryService, draft.extraServices);
|
|
||||||
|
|
||||||
if (extraServices) {
|
|
||||||
payload.extraServices = extraServices;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceNotes = trimOrUndefined(draft.pmNote);
|
const serviceNotes = trimOrUndefined(draft.pmNote);
|
||||||
|
|
||||||
if (serviceNotes) {
|
if (serviceNotes) {
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,13 @@ vi.mock("@/domain/work-orders/use-cases/use-create-work-order-from-wizard", () =
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
useWorkOrderLocations: () => ({ data: [] }),
|
|
||||||
useWorkOrderVendors: () => ({ data: [] }),
|
useWorkOrderVendors: () => ({ data: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
|
||||||
|
useLocationSites: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||||
useLocationDetail: () => ({ data: undefined }),
|
useLocationDetail: () => ({ data: undefined }),
|
||||||
}));
|
}));
|
||||||
|
|
@ -45,7 +48,7 @@ describe("NewWoWizard duplicate wo number", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const woNumberInput = screen.getByPlaceholderText("e.g. 24818");
|
const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901");
|
||||||
fireEvent.change(woNumberInput, {
|
fireEvent.change(woNumberInput, {
|
||||||
target: { value: "12345678901" },
|
target: { value: "12345678901" },
|
||||||
});
|
});
|
||||||
|
|
@ -74,7 +77,7 @@ describe("NewWoWizard duplicate wo number", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const woNumberInput = screen.getByPlaceholderText("e.g. 24818");
|
const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901");
|
||||||
fireEvent.change(woNumberInput, {
|
fireEvent.change(woNumberInput, {
|
||||||
target: { value: "98765432109" },
|
target: { value: "98765432109" },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import {
|
||||||
|
EMPTY_WIZARD_DRAFT,
|
||||||
|
type WorkOrderWizardDraft,
|
||||||
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function baseLocation(overrides: Partial<Location> = {}): Location {
|
||||||
|
return {
|
||||||
|
id: "loc-1",
|
||||||
|
name: "BK5",
|
||||||
|
contact: "Janelle Pickett",
|
||||||
|
phone: "214-555-0111",
|
||||||
|
...overrides,
|
||||||
|
} as Location;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useWizardPocAutofill", () => {
|
||||||
|
it("fills empty POC fields from location detail once", () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [draft, setDraft] = useState<WorkOrderWizardDraft>({ ...EMPTY_WIZARD_DRAFT });
|
||||||
|
useWizardPocAutofill(true, "loc-1", baseLocation(), setDraft);
|
||||||
|
return draft;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.pocName).toBe("Janelle Pickett");
|
||||||
|
expect(result.current.pocPhone).toBe("214-555-0111");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite POC edits made before location detail arrives", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ detail }: { detail: Location | undefined }) => {
|
||||||
|
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
pocName: "Dispatcher Typed",
|
||||||
|
pocPhone: "999-000-1111",
|
||||||
|
});
|
||||||
|
useWizardPocAutofill(true, "loc-1", detail, setDraft);
|
||||||
|
return draft;
|
||||||
|
},
|
||||||
|
{ initialProps: { detail: undefined as Location | undefined } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.pocName).toBe("Dispatcher Typed");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
rerender({ detail: baseLocation() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.pocName).toBe("Dispatcher Typed");
|
||||||
|
expect(result.current.pocPhone).toBe("999-000-1111");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-seeds POC after re-selecting the same location clears the fields", () => {
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
const [draft, setDraft] = useState<WorkOrderWizardDraft>({ ...EMPTY_WIZARD_DRAFT });
|
||||||
|
const autofill = useWizardPocAutofill(true, "loc-1", baseLocation(), setDraft);
|
||||||
|
return { draft, setDraft, autofill };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.draft.pocName).toBe("Janelle Pickett");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
}));
|
||||||
|
result.current.autofill.notifyLocationChanged("loc-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.draft.pocName).toBe("Janelle Pickett");
|
||||||
|
expect(result.current.draft.pocPhone).toBe("214-555-0111");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
||||||
|
import {
|
||||||
|
EMPTY_WIZARD_DRAFT,
|
||||||
|
type WorkOrderWizardDraft,
|
||||||
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
function baseDraft(overrides: Partial<WorkOrderWizardDraft> = {}): WorkOrderWizardDraft {
|
||||||
|
return { ...EMPTY_WIZARD_DRAFT, type: "PM", ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMonthIso(day: number): string {
|
||||||
|
const [y, m] = todayIso().split("-").map(Number);
|
||||||
|
return `${y}-${pad2(m)}-${pad2(day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScheduleRangePicker() {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /pick a date/i }));
|
||||||
|
const rangeTab = await screen.findByRole("button", { name: "Range" });
|
||||||
|
fireEvent.click(rangeTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
||||||
|
it("clicks start then end in order and submits schedMode range with matching dates", async () => {
|
||||||
|
const onUpdate = vi.fn();
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WizardStepTypeSchedule
|
||||||
|
draft={baseDraft()}
|
||||||
|
users={[]}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onPatch={onPatch}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await openScheduleRangePicker();
|
||||||
|
|
||||||
|
const startIso = currentMonthIso(5);
|
||||||
|
const endIso = currentMonthIso(20);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "5" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "20" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^done$/i }));
|
||||||
|
|
||||||
|
expect(onPatch).toHaveBeenCalledWith({
|
||||||
|
scheduledOn: startIso,
|
||||||
|
scheduleEnd: endIso,
|
||||||
|
schedMode: "range",
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
targetWeek: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes an out-of-order click (end clicked before start) into start<end on submission", async () => {
|
||||||
|
const onUpdate = vi.fn();
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WizardStepTypeSchedule
|
||||||
|
draft={baseDraft()}
|
||||||
|
users={[]}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onPatch={onPatch}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await openScheduleRangePicker();
|
||||||
|
|
||||||
|
const laterIso = currentMonthIso(20);
|
||||||
|
const earlierIso = currentMonthIso(5);
|
||||||
|
|
||||||
|
// User clicks the later date first, then an earlier date — the field must
|
||||||
|
// normalize this into (start = earlier, end = later) before submission.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "20" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "5" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^done$/i }));
|
||||||
|
|
||||||
|
expect(onPatch).toHaveBeenCalledWith({
|
||||||
|
scheduledOn: earlierIso,
|
||||||
|
scheduleEnd: laterIso,
|
||||||
|
schedMode: "range",
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
targetWeek: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit until both a start and end date are picked", async () => {
|
||||||
|
const onUpdate = vi.fn();
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WizardStepTypeSchedule
|
||||||
|
draft={baseDraft()}
|
||||||
|
users={[]}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onPatch={onPatch}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await openScheduleRangePicker();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "5" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled();
|
||||||
|
expect(onPatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select";
|
||||||
|
import {
|
||||||
|
EMPTY_WIZARD_DRAFT,
|
||||||
|
type WorkOrderWizardDraft,
|
||||||
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
describe("WizardStepLocationServiceSelect — vendor clear on service change", () => {
|
||||||
|
it("clears vendor fields when the primary service changes", () => {
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
const draft: WorkOrderWizardDraft = {
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
pm: "HVAC",
|
||||||
|
vendorId: "9",
|
||||||
|
vendorName: "Old Vendor",
|
||||||
|
techPhone: "555-0100",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WizardStepLocationServiceSelect
|
||||||
|
draft={draft}
|
||||||
|
services={[
|
||||||
|
{ id: "1", value: "HVAC" },
|
||||||
|
{ id: "2", value: "Plumbing" },
|
||||||
|
]}
|
||||||
|
svcOpen={false}
|
||||||
|
onSvcOpenChange={vi.fn()}
|
||||||
|
svcSearch=""
|
||||||
|
onSvcSearchChange={vi.fn()}
|
||||||
|
onUpdate={vi.fn()}
|
||||||
|
onPatch={onPatch}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Plumbing" }));
|
||||||
|
|
||||||
|
expect(onPatch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
pm: "Plumbing",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "",
|
||||||
|
vendorName: "",
|
||||||
|
techPhone: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the selected service in Service & Notes without persisting extraServices", () => {
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
const draft: WorkOrderWizardDraft = {
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
pm: "HVAC",
|
||||||
|
extraServices: ["Plumbing"],
|
||||||
|
vendorId: "9",
|
||||||
|
vendorName: "Old Vendor",
|
||||||
|
techPhone: "555-0100",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WizardStepLocationServiceSelect
|
||||||
|
draft={draft}
|
||||||
|
services={[
|
||||||
|
{ id: "1", value: "HVAC" },
|
||||||
|
{ id: "2", value: "Plumbing" },
|
||||||
|
{ id: "3", value: "Electrical" },
|
||||||
|
]}
|
||||||
|
svcOpen={true}
|
||||||
|
onSvcOpenChange={vi.fn()}
|
||||||
|
svcSearch=""
|
||||||
|
onSvcSearchChange={vi.fn()}
|
||||||
|
onUpdate={vi.fn()}
|
||||||
|
onPatch={onPatch}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Electrical" }));
|
||||||
|
|
||||||
|
expect(onPatch).toHaveBeenCalledWith({
|
||||||
|
pm: "Electrical",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "",
|
||||||
|
vendorName: "",
|
||||||
|
techPhone: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -122,8 +122,6 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
|
|
||||||
primaryService: "HVAC PM",
|
primaryService: "HVAC PM",
|
||||||
|
|
||||||
extraServices: ["Filter change", "Coil clean"],
|
|
||||||
|
|
||||||
serviceNotes: "Check unit on roof",
|
serviceNotes: "Check unit on roof",
|
||||||
|
|
||||||
pocName: "Jane Site Lead",
|
pocName: "Jane Site Lead",
|
||||||
|
|
@ -141,6 +139,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
|
|
||||||
expect(payload.trade).toBeUndefined();
|
expect(payload.trade).toBeUndefined();
|
||||||
|
|
||||||
|
expect(payload.extraServices).toBeUndefined();
|
||||||
|
|
||||||
expect(payload.description).toBeUndefined();
|
expect(payload.description).toBeUndefined();
|
||||||
|
|
||||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||||
|
|
@ -269,7 +269,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
).toThrow("Site code is required");
|
).toThrow("Site code is required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits scheduledDate for range schedule and puts range in description only", () => {
|
it("sets scheduledDate from range start and keeps range text in description", () => {
|
||||||
const payload = mapWizardDraftToBoardCreatePayload({
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
||||||
|
|
@ -288,7 +288,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
vendorNotes: "Also not in description",
|
vendorNotes: "Also not in description",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.scheduledDate).toBeUndefined();
|
expect(payload.scheduledDate).toBe("2026-06-08");
|
||||||
|
|
||||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||||
|
|
||||||
|
|
@ -309,7 +309,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
expect(payload.vendorNotes).toBe("Also not in description");
|
expect(payload.vendorNotes).toBe("Also not in description");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps service notes and extras as structured fields instead of description", () => {
|
it("maps service notes as structured fields and omits extraServices from wizard create", () => {
|
||||||
const payload = mapWizardDraftToBoardCreatePayload({
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
||||||
|
|
@ -326,7 +326,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
|
|
||||||
expect(payload.primaryService).toBe("HVAC PM");
|
expect(payload.primaryService).toBe("HVAC PM");
|
||||||
|
|
||||||
expect(payload.extraServices).toEqual(["Filter change", "Coil clean"]);
|
expect(payload.extraServices).toBeUndefined();
|
||||||
|
|
||||||
expect(payload.serviceNotes).toBe("Check unit on roof");
|
expect(payload.serviceNotes).toBe("Check unit on roof");
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue