mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
refactor(work-orders): split wizard components and cover date range UI
Extract focused wizard step/date helpers for the maintainability gate and add a public-interface test for range click order and schedule submission.
This commit is contained in:
parent
27131a1533
commit
77ad0325f0
23 changed files with 1253 additions and 722 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 { 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";
|
||||
|
||||
const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
|
@ -93,44 +94,14 @@ export function MiniCalendar({
|
|||
}
|
||||
const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
|
||||
const isToday = iso === today;
|
||||
|
||||
let bg = "transparent";
|
||||
let fg: string = isToday ? "var(--color-primary)" : "var(--color-text-primary)";
|
||||
let radius: number | string = 4;
|
||||
let filled = false;
|
||||
|
||||
if (range && rangeStart) {
|
||||
const lo = !rangeEnd ? rangeStart : rangeStart <= rangeEnd ? rangeStart : rangeEnd;
|
||||
const hi = !rangeEnd ? rangeStart : rangeStart <= rangeEnd ? rangeEnd : rangeStart;
|
||||
if (lo === hi) {
|
||||
if (iso === lo) {
|
||||
filled = true;
|
||||
bg = "var(--color-primary)";
|
||||
fg = "var(--color-primary-foreground)";
|
||||
radius = 4;
|
||||
}
|
||||
} else if (iso === lo) {
|
||||
filled = true;
|
||||
bg = "var(--color-primary)";
|
||||
fg = "var(--color-primary-foreground)";
|
||||
radius = "4px 0 0 4px";
|
||||
} else if (iso === hi) {
|
||||
filled = true;
|
||||
bg = "var(--color-primary)";
|
||||
fg = "var(--color-primary-foreground)";
|
||||
radius = "0 4px 4px 0";
|
||||
} else if (iso > lo && iso < hi) {
|
||||
filled = true;
|
||||
bg = "var(--color-primary-tint)";
|
||||
fg = "var(--color-primary)";
|
||||
radius = 0;
|
||||
}
|
||||
} else if (iso === value) {
|
||||
filled = true;
|
||||
bg = "var(--color-primary)";
|
||||
fg = "var(--color-primary-foreground)";
|
||||
radius = 4;
|
||||
}
|
||||
const { bg, fg, radius, filled } = getMiniCalendarCellStyle({
|
||||
iso,
|
||||
value,
|
||||
isToday,
|
||||
range,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Dialog } from "@mui/material";
|
||||
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
import {
|
||||
EMPTY_WIZARD_DRAFT,
|
||||
WIZARD_STEPS,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
|
||||
|
|
@ -28,67 +20,37 @@ import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wi
|
|||
|
||||
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 { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
||||
|
||||
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
||||
|
||||
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
|
||||
|
||||
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
|
||||
|
||||
type NewWoWizardProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
users: WorkOrderUser[];
|
||||
};
|
||||
|
||||
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({ open, onOpenChange, users }: NewWoWizardProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
status: "Incomplete",
|
||||
});
|
||||
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
|
||||
const [svcOpen, setSvcOpen] = useState(false);
|
||||
const [svcSearch, setSvcSearch] = useState("");
|
||||
const {
|
||||
step,
|
||||
setStep,
|
||||
draft,
|
||||
setDraft,
|
||||
setField,
|
||||
patchDraft: patchDraftOnly,
|
||||
svcOpen,
|
||||
setSvcOpen,
|
||||
svcSearch,
|
||||
setSvcSearch,
|
||||
} = useNewWoWizardDraft(open);
|
||||
|
||||
const { data: locations = [] } = useLocationSites();
|
||||
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
||||
|
|
@ -102,47 +64,21 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
|
|||
locationDetail?.zipCode,
|
||||
);
|
||||
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep(1);
|
||||
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
|
||||
setPocAutoFilledFor("");
|
||||
setSvcOpen(false);
|
||||
setSvcSearch("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft.locationId || !locationDetail) {
|
||||
return;
|
||||
}
|
||||
if (pocAutoFilledFor === draft.locationId) {
|
||||
return;
|
||||
}
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
pocName: locationDetail.contact ?? current.pocName,
|
||||
pocPhone: locationDetail.phone ?? current.pocPhone,
|
||||
}));
|
||||
setPocAutoFilledFor(draft.locationId);
|
||||
}, [draft.locationId, locationDetail, pocAutoFilledFor]);
|
||||
|
||||
const setField = <K extends keyof WorkOrderWizardDraft>(
|
||||
key: K,
|
||||
value: WorkOrderWizardDraft[K],
|
||||
) => {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
const { notifyLocationChanged } = useWizardPocAutofill(
|
||||
open,
|
||||
draft.locationId,
|
||||
locationDetail,
|
||||
setDraft,
|
||||
);
|
||||
|
||||
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) {
|
||||
setPocAutoFilledFor("");
|
||||
patchDraftOnly(patch);
|
||||
if (patch.locationId !== undefined) {
|
||||
notifyLocationChanged(patch.locationId);
|
||||
}
|
||||
};
|
||||
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
|
||||
const handleCreate = () => createMutation.mutate(draft);
|
||||
|
||||
const isCreating = createMutation.isPending;
|
||||
|
|
|
|||
|
|
@ -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,79 @@
|
|||
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) {
|
||||
setRangeStart(value || "");
|
||||
setRangeEnd(scheduleEnd || "");
|
||||
setCellMode(initialMode);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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,41 @@
|
|||
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. */
|
||||
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: locationDetail.contact ?? current.pocName,
|
||||
pocPhone: locationDetail.phone ?? current.pocPhone,
|
||||
}));
|
||||
setPocAutoFilledFor(locationId);
|
||||
}, [locationId, locationDetail, pocAutoFilledFor, setDraft]);
|
||||
|
||||
function notifyLocationChanged(newLocationId: string) {
|
||||
if (newLocationId !== pocAutoFilledFor) {
|
||||
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,16 +1,12 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { CalendarDays, CalendarIcon, CalendarRange, ChevronDown } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
|
||||
import { WizardDateFieldTrigger } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field-trigger";
|
||||
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,
|
||||
rangeDayCount,
|
||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||
import { fmtDate, fmtDateRange, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||
|
||||
type WizardDateFieldProps = {
|
||||
|
|
@ -25,6 +21,24 @@ type WizardDateFieldProps = {
|
|||
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({
|
||||
value,
|
||||
placeholder,
|
||||
|
|
@ -39,110 +53,37 @@ export function WizardDateField({
|
|||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
const allowRange = Boolean(allowWeekMode && onChangeRange);
|
||||
|
||||
const initialMode: WizardSchedMode =
|
||||
allowRange && schedMode === "range"
|
||||
? "range"
|
||||
: allowWeekMode && !value && targetWeek
|
||||
? "week"
|
||||
: "date";
|
||||
|
||||
const [cellMode, setCellMode] = useState<WizardSchedMode>(initialMode);
|
||||
const [rangeStart, setRangeStart] = useState(value || "");
|
||||
const [rangeEnd, setRangeEnd] = useState(scheduleEnd || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRangeStart(value || "");
|
||||
setRangeEnd(scheduleEnd || "");
|
||||
setCellMode(initialMode);
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
|
||||
{
|
||||
open,
|
||||
value,
|
||||
scheduleEnd,
|
||||
allowRange,
|
||||
allowWeekMode,
|
||||
targetWeek,
|
||||
schedMode,
|
||||
},
|
||||
);
|
||||
|
||||
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
|
||||
const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
|
||||
|
||||
function handleRangeClick(iso: string) {
|
||||
if (!rangeStart || (rangeStart && rangeEnd)) {
|
||||
setRangeStart(iso);
|
||||
setRangeEnd("");
|
||||
return;
|
||||
}
|
||||
if (iso < rangeStart) {
|
||||
setRangeStart(iso);
|
||||
setRangeEnd(rangeStart);
|
||||
} else {
|
||||
setRangeEnd(iso);
|
||||
}
|
||||
}
|
||||
|
||||
const modes: WizardSchedMode[] = allowRange
|
||||
? ["week", "date", "range"]
|
||||
: allowWeekMode
|
||||
? ["week", "date"]
|
||||
: [];
|
||||
|
||||
const modeLabel = (m: WizardSchedMode) =>
|
||||
m === "week" ? "Week" : m === "range" ? "Range" : allowRange ? "Date" : "Specific date";
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuAnchorButton
|
||||
<WizardDateFieldTrigger
|
||||
onAnchor={setAnchor}
|
||||
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"
|
||||
>
|
||||
{showRange ? (
|
||||
<CalendarRange
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
/>
|
||||
) : 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 || showRange ? "var(--foreground)" : "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{showRange
|
||||
? fmtDateRange(value, scheduleEnd)
|
||||
: showWeek
|
||||
? fmtWeekRange(targetWeek)
|
||||
: value
|
||||
? fmtDate(value)
|
||||
: placeholder}
|
||||
</span>
|
||||
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
</MenuAnchorButton>
|
||||
label={fieldLabel({ showRange, showWeek, value, scheduleEnd, targetWeek, placeholder })}
|
||||
showWeek={showWeek}
|
||||
showRange={showRange}
|
||||
hasValue={Boolean(value)}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
|
|
@ -157,35 +98,12 @@ export function WizardDateField({
|
|||
},
|
||||
}}
|
||||
>
|
||||
{modes.length > 0 && (
|
||||
<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={() => 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)",
|
||||
}}
|
||||
>
|
||||
{modeLabel(m)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<WizardDateModeTabs
|
||||
modes={modes}
|
||||
cellMode={cellMode}
|
||||
allowRange={allowRange}
|
||||
onSelect={setCellMode}
|
||||
/>
|
||||
|
||||
{allowWeekMode && cellMode === "week" ? (
|
||||
<WeekPicker
|
||||
|
|
@ -197,51 +115,15 @@ export function WizardDateField({
|
|||
}}
|
||||
/>
|
||||
) : allowRange && cellMode === "range" ? (
|
||||
<>
|
||||
<MiniCalendar
|
||||
range
|
||||
value={rangeStart}
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
onChange={handleRangeClick}
|
||||
/>
|
||||
{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={!rangeStart || !rangeEnd}
|
||||
onClick={() => {
|
||||
if (rangeStart && rangeEnd) {
|
||||
onChangeRange?.(rangeStart, rangeEnd);
|
||||
close();
|
||||
}
|
||||
}}
|
||||
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: !rangeStart || !rangeEnd ? 0.5 : 1,
|
||||
pointerEvents: !rangeStart || !rangeEnd ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</>
|
||||
<WizardDateRangePanel
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
onRangeClick={handleRangeClick}
|
||||
onDone={(start, end) => {
|
||||
onChangeRange?.(start, end);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MiniCalendar
|
||||
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,17 +1,12 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
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";
|
||||
|
||||
export interface WizardMenuOption {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
}
|
||||
export type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types";
|
||||
|
||||
type WizardFieldSelectProps = {
|
||||
value: string;
|
||||
|
|
@ -24,6 +19,19 @@ type WizardFieldSelectProps = {
|
|||
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({
|
||||
value,
|
||||
placeholder,
|
||||
|
|
@ -37,16 +45,7 @@ export function WizardFieldSelect({
|
|||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
const [q, setQ] = useState("");
|
||||
const selected = options.find((o) => o.key === value);
|
||||
const filtered = q
|
||||
? options.filter((o) => {
|
||||
const needle = q.toLowerCase();
|
||||
return (
|
||||
o.label.toLowerCase().includes(needle) ||
|
||||
o.key.toLowerCase().includes(needle) ||
|
||||
(o.description?.toLowerCase().includes(needle) ?? false)
|
||||
);
|
||||
})
|
||||
: options;
|
||||
const filtered = filterOptions(options, q);
|
||||
|
||||
const handleClose = () => {
|
||||
close();
|
||||
|
|
@ -114,70 +113,18 @@ export function WizardFieldSelect({
|
|||
},
|
||||
}}
|
||||
>
|
||||
{search && (
|
||||
<div
|
||||
className="mb-1 px-1 pb-1.5"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(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" }}>
|
||||
{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="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>
|
||||
<WizardFieldSelectOptions
|
||||
search={search}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
query={q}
|
||||
onQueryChange={setQ}
|
||||
options={filtered}
|
||||
value={value}
|
||||
onPick={(key) => {
|
||||
onPick(key);
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
</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,20 +1,10 @@
|
|||
import { ClipboardList } from "lucide-react";
|
||||
import {
|
||||
WIZARD_STATUS_DOTS,
|
||||
WIZARD_STEPS,
|
||||
type WizardWOStatus,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import {
|
||||
fmtDate,
|
||||
fmtDateRange,
|
||||
fmtWeekRange,
|
||||
formatApptRange,
|
||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import {
|
||||
ColorDot,
|
||||
StepCircle,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { StepCircle } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { WizardSidebarSummary } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary";
|
||||
|
||||
type WizardSidebarProps = {
|
||||
step: number;
|
||||
|
|
@ -121,108 +111,3 @@ export function WizardSidebar({ step, draft }: WizardSidebarProps) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardSidebarSummary({ draft }: { draft: WorkOrderWizardDraft }) {
|
||||
const summary: [string, string][] = [
|
||||
["WO #", draft.woNumber ? `#${draft.woNumber}` : "—"],
|
||||
["Type", draft.type || "—"],
|
||||
[
|
||||
"Schedule on",
|
||||
draft.schedMode === "range" && draft.scheduledOn && draft.scheduleEnd
|
||||
? fmtDateRange(draft.scheduledOn, draft.scheduleEnd)
|
||||
: draft.scheduledOn
|
||||
? draft.scheduleWeekOnly
|
||||
? fmtWeekRange(draft.targetWeek || draft.scheduledOn)
|
||||
: fmtDate(draft.scheduledOn)
|
||||
: "—",
|
||||
],
|
||||
["Due date", fmtDate(draft.dueDate) || "—"],
|
||||
["Assigned to", draft.assigneeName || "—"],
|
||||
["Site", draft.siteCode || 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,103 @@
|
|||
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 svcSel = [draft.pm, ...draft.extraServices].filter((p, i, a) => !!p && a.indexOf(p) === i);
|
||||
|
||||
const toggleSvc = (p: string) => {
|
||||
const next = svcSel.includes(p) ? svcSel.filter((x) => x !== p) : [...svcSel, p];
|
||||
onPatch({
|
||||
pm: next[0] ?? "",
|
||||
extraServices: next.slice(1),
|
||||
});
|
||||
};
|
||||
|
||||
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={(p) =>
|
||||
onPatch({
|
||||
pm: p,
|
||||
extraServices: draft.extraServices.filter((x) => x !== p),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{!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={svcSel}
|
||||
onToggle={toggleSvc}
|
||||
note={draft.pmNote}
|
||||
setNote={(v) => onUpdate("pmNote", v)}
|
||||
search={svcSearch}
|
||||
setSearch={onSvcSearchChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +1,11 @@
|
|||
import { Droplets, ExternalLink, Info, Mail, MapPin, Phone, Plus } 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 { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
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 {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import {
|
||||
Detail,
|
||||
WizLabel,
|
||||
WizSubLabel,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { WizardStepLocationPocFields } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields";
|
||||
import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select";
|
||||
import { WizardStepLocationSiteDetails } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details";
|
||||
import { WizardStepLocationSiteSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select";
|
||||
|
||||
type ServiceOption = { id: string | number; value: string };
|
||||
|
||||
|
|
@ -35,11 +24,6 @@ type WizardStepLocationServiceProps = {
|
|||
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||
};
|
||||
|
||||
function buildMapsUrl(location: Location): string {
|
||||
const query = formatLocationAddressPreview(location);
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
export function WizardStepLocationService({
|
||||
draft,
|
||||
locations,
|
||||
|
|
@ -55,213 +39,38 @@ export function WizardStepLocationService({
|
|||
onPatch,
|
||||
}: WizardStepLocationServiceProps) {
|
||||
const selectedLoc = locations.find((l) => String(l.id) === draft.locationId);
|
||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||
const svcSel = [draft.pm, ...draft.extraServices].filter((p, i, a) => !!p && a.indexOf(p) === i);
|
||||
|
||||
const toggleSvc = (p: string) => {
|
||||
const next = svcSel.includes(p) ? svcSel.filter((x) => x !== p) : [...svcSel, p];
|
||||
onPatch({
|
||||
pm: next[0] ?? "",
|
||||
extraServices: next.slice(1),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<WizLabel required>Site</WizLabel>
|
||||
<WizardFieldSelect
|
||||
value={draft.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);
|
||||
onPatch({
|
||||
locationId: id,
|
||||
locationName: loc?.name ?? "",
|
||||
siteCode: loc ? resolveSiteCode(loc) : "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!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>
|
||||
<WizardStepLocationSiteSelect
|
||||
locationId={draft.locationId}
|
||||
locations={locations}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
|
||||
{draft.locationId !== "" && (
|
||||
<>
|
||||
<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 || draft.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 || draft.locationName || "—"} />
|
||||
{Boolean(locationDetailLoading) && (
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
<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>
|
||||
<WizardStepLocationSiteDetails
|
||||
selectedLoc={selectedLoc}
|
||||
locationName={draft.locationName}
|
||||
locationDetail={locationDetail}
|
||||
locationDetailLoading={locationDetailLoading}
|
||||
locationDetailError={locationDetailError}
|
||||
/>
|
||||
<WizardStepLocationPocFields draft={draft} onUpdate={onUpdate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<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={(p) =>
|
||||
onPatch({
|
||||
pm: p,
|
||||
extraServices: draft.extraServices.filter((x) => x !== p),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{!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={svcSel}
|
||||
onToggle={toggleSvc}
|
||||
note={draft.pmNote}
|
||||
setNote={(v) => onUpdate("pmNote", v)}
|
||||
search={svcSearch}
|
||||
setSearch={onSvcSearchChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<WizardStepLocationServiceSelect
|
||||
draft={draft}
|
||||
services={services}
|
||||
svcOpen={svcOpen}
|
||||
onSvcOpenChange={onSvcOpenChange}
|
||||
svcSearch={svcSearch}
|
||||
onSvcSearchChange={onSvcSearchChange}
|
||||
onUpdate={onUpdate}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
|
||||
{!draft.locationId && !draft.pm && (
|
||||
<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,55 @@
|
|||
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);
|
||||
onPatch({
|
||||
locationId: id,
|
||||
locationName: loc?.name ?? "",
|
||||
siteCode: loc ? resolveSiteCode(loc) : "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue