mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 06:13:13 +00:00
Merge pull request #103 from Sea-Haven-Industries/fix/SH-109-inline-save-unscheduled
fix(work-orders): allow inline save without Schedule On (SH-109)
This commit is contained in:
commit
489469d330
18 changed files with 827 additions and 189 deletions
|
|
@ -12,12 +12,15 @@ export function buildInlineDraftCreateInput(
|
|||
const location = sites.find((s) => String(s.id) === String(draft.locationId));
|
||||
const locationName = draft.location || draft.site || location?.name || "";
|
||||
|
||||
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
|
||||
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
|
||||
|
||||
return {
|
||||
locationId: String(draft.locationId || ""),
|
||||
locationName,
|
||||
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||
woNumber: draft.woNumber,
|
||||
type: draft.type,
|
||||
type: draft.type || "PM",
|
||||
assigneeId: draft.dispatcherId,
|
||||
assigneeName: assignee?.name ?? "",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
|
|
@ -35,7 +38,7 @@ export function buildInlineDraftCreateInput(
|
|||
pocName: draft.pocName,
|
||||
pocPhone: draft.pocPhone,
|
||||
pocNotes: draft.pocNotes,
|
||||
scheduleWeekOnly: draft.scheduleWeekOnly,
|
||||
scheduleWeekOnly,
|
||||
targetWeek: draft.targetWeek || "",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ type DateCellProps = {
|
|||
targetWeek?: string;
|
||||
onSaveWeek?: (weekStart: string) => void;
|
||||
defaultMode?: "week" | "date";
|
||||
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export function DateCell({
|
||||
|
|
@ -26,6 +28,7 @@ export function DateCell({
|
|||
targetWeek = "",
|
||||
onSaveWeek,
|
||||
defaultMode,
|
||||
onClear,
|
||||
}: DateCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [cellMode, setCellMode] = useState<"week" | "date">(
|
||||
|
|
@ -132,6 +135,21 @@ export function DateCell({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="text-[11.5px] font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
× Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ type SiteCellProps = {
|
|||
pocNotes?: string;
|
||||
sites: LocationOption[];
|
||||
q?: string;
|
||||
/** Inline create: editable POC + Site/POC required to confirm the modal. */
|
||||
createMode?: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
|
|
@ -31,6 +33,7 @@ export function SiteCell({
|
|||
pocNotes,
|
||||
sites,
|
||||
q,
|
||||
createMode = false,
|
||||
onSave,
|
||||
}: SiteCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -70,7 +73,15 @@ export function SiteCell({
|
|||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[12.5px] text-[var(--color-text-muted)]">—</span>
|
||||
<span className="text-[12.5px] text-[var(--color-text-muted)]">
|
||||
{createMode ? (
|
||||
<>
|
||||
Site <span style={{ color: "var(--destructive)" }}>*</span>
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
|
@ -83,6 +94,7 @@ export function SiteCell({
|
|||
pocPhone={pocPhone}
|
||||
pocNotes={pocNotes}
|
||||
sites={sites}
|
||||
createMode={createMode}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
|
|||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
editable?: boolean;
|
||||
showErrors?: boolean;
|
||||
onPocNameChange?: (value: string) => void;
|
||||
onPocPhoneChange?: (value: string) => void;
|
||||
onPocNotesChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) {
|
||||
export function SiteDialogPocFields({
|
||||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
editable = false,
|
||||
showErrors = false,
|
||||
onPocNameChange,
|
||||
onPocPhoneChange,
|
||||
onPocNotesChange,
|
||||
}: SiteDialogPocFieldsProps) {
|
||||
const nameMissing = showErrors && !pocName.trim();
|
||||
const phoneMissing = showErrors && !pocPhone.trim();
|
||||
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<FieldLabel>Point of Contact</FieldLabel>
|
||||
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
Shown from site/work order records. Board edit saves site code only.
|
||||
{editable
|
||||
? "Auto-filled from records — edit if outdated"
|
||||
: "Shown from site/work order records. Board edit saves site code only."}
|
||||
</Text>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={pocName}
|
||||
readOnly
|
||||
placeholder="POC name"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||
/>
|
||||
<input
|
||||
value={pocPhone}
|
||||
readOnly
|
||||
placeholder="POC phone"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
value={pocName}
|
||||
readOnly={!editable}
|
||||
placeholder="POC name"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={{
|
||||
...DLG_INPUT_STYLE,
|
||||
opacity: editable ? 1 : 0.85,
|
||||
borderColor: nameMissing ? "var(--destructive)" : undefined,
|
||||
}}
|
||||
onChange={(e) => onPocNameChange?.(e.target.value)}
|
||||
/>
|
||||
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
|
||||
Contact name is required
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
value={pocPhone}
|
||||
readOnly={!editable}
|
||||
placeholder="POC phone"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={{
|
||||
...DLG_INPUT_STYLE,
|
||||
opacity: editable ? 1 : 0.85,
|
||||
borderColor: phoneMissing ? "var(--destructive)" : undefined,
|
||||
}}
|
||||
onChange={(e) => onPocPhoneChange?.(e.target.value)}
|
||||
/>
|
||||
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
|
||||
Contact phone is required
|
||||
</Text>
|
||||
</div>
|
||||
<textarea
|
||||
value={pocNotes}
|
||||
readOnly
|
||||
readOnly={!editable}
|
||||
placeholder="Notes…"
|
||||
rows={2}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: editable ? 1 : 0.85 }}
|
||||
onChange={(e) => onPocNotesChange?.(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,20 @@
|
|||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
import {
|
||||
extractLocationLabel,
|
||||
resolveLocationId,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
|
||||
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
|
||||
import {
|
||||
useSiteDialogState,
|
||||
type SitePatch,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
|
||||
|
||||
export type SitePatch = {
|
||||
site: string;
|
||||
location: string;
|
||||
locationId: string | number;
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
};
|
||||
export type { SitePatch };
|
||||
|
||||
type SiteDialogProps = {
|
||||
open: boolean;
|
||||
|
|
@ -35,6 +25,7 @@ type SiteDialogProps = {
|
|||
pocPhone?: string;
|
||||
pocNotes?: string;
|
||||
sites: LocationOption[];
|
||||
createMode?: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
|
|
@ -47,62 +38,26 @@ export function SiteDialog({
|
|||
pocPhone = "",
|
||||
pocNotes = "",
|
||||
sites,
|
||||
createMode = false,
|
||||
onSave,
|
||||
}: SiteDialogProps) {
|
||||
const [code, setCode] = useState(value);
|
||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
||||
const [pn, setPn] = useState(pocName);
|
||||
const [pp, setPp] = useState(pocPhone);
|
||||
const [notes, setNotes] = useState(pocNotes);
|
||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||
|
||||
const {
|
||||
data: locationDetail,
|
||||
isLoading: locationDetailLoading,
|
||||
isError: locationDetailError,
|
||||
} = useLocationDetail(open && locId ? locId : undefined);
|
||||
|
||||
const selected = sites.find((s) => String(s.id) === locId);
|
||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveLocationId(locationId, value, sites);
|
||||
setCode(value);
|
||||
setLocId(resolved);
|
||||
setPn(pocName);
|
||||
setPp(pocPhone);
|
||||
setNotes(pocNotes);
|
||||
// Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
|
||||
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
return;
|
||||
}
|
||||
setPn(locationDetail.contact ?? "");
|
||||
setPp(locationDetail.phone ?? "");
|
||||
setPocFilledFor(locId);
|
||||
}, [open, locId, locationDetail, pocFilledFor]);
|
||||
|
||||
const handlePick = (id: string) => {
|
||||
const next = sites.find((s) => String(s.id) === id);
|
||||
setLocId(id);
|
||||
if (next) {
|
||||
setCode(resolveSiteCode(next));
|
||||
}
|
||||
if (id !== pocFilledFor) {
|
||||
setPocFilledFor("");
|
||||
}
|
||||
};
|
||||
const s = useSiteDialogState({
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
locationId,
|
||||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
sites,
|
||||
createMode,
|
||||
onSave,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onClose={s.close}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
|
|
@ -125,7 +80,7 @@ export function SiteDialog({
|
|||
<div>
|
||||
<FieldLabel>Site</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={locId}
|
||||
value={s.locId}
|
||||
placeholder="Select site …"
|
||||
icon={
|
||||
<MapPin
|
||||
|
|
@ -139,45 +94,45 @@ export function SiteDialog({
|
|||
key: String(site.id),
|
||||
label: site.name,
|
||||
}))}
|
||||
onPick={handlePick}
|
||||
onPick={s.handlePick}
|
||||
/>
|
||||
<Text
|
||||
as="p"
|
||||
variant="error"
|
||||
when={Boolean(createMode && s.showErrors && s.siteMissing)}
|
||||
className="mt-1 text-[11px]"
|
||||
>
|
||||
Site is required
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{Boolean(locId) && (
|
||||
{Boolean(s.locId) && (
|
||||
<SiteDialogDetailPanel
|
||||
locationDetail={locationDetail}
|
||||
locationDetailLoading={locationDetailLoading}
|
||||
locationDetailError={locationDetailError}
|
||||
addressPreview={addressPreview}
|
||||
fallbackLabel={selected?.name || code || "—"}
|
||||
locationDetail={s.locationDetail}
|
||||
locationDetailLoading={s.locationDetailLoading}
|
||||
locationDetailError={s.locationDetailError}
|
||||
addressPreview={s.addressPreview}
|
||||
fallbackLabel={s.selected?.name || s.code || "—"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SiteDialogPocFields pocName={pn} pocPhone={pp} pocNotes={notes} />
|
||||
<SiteDialogPocFields
|
||||
pocName={s.pn}
|
||||
pocPhone={s.pp}
|
||||
pocNotes={s.notes}
|
||||
editable={createMode}
|
||||
showErrors={createMode && s.showErrors}
|
||||
onPocNameChange={s.setPn}
|
||||
onPocPhoneChange={s.setPp}
|
||||
onPocNotesChange={s.setNotes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({
|
||||
site: code || (selected ? resolveSiteCode(selected) : ""),
|
||||
location: selected ? extractLocationLabel(selected) : "",
|
||||
locationId: locId,
|
||||
pocName: pn,
|
||||
pocPhone: pp,
|
||||
pocNotes: notes,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
extractLocationLabel,
|
||||
resolveLocationId,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||
|
||||
export type SitePatch = {
|
||||
site: string;
|
||||
location: string;
|
||||
locationId: string | number;
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
};
|
||||
|
||||
type UseSiteDialogStateArgs = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
value: string;
|
||||
locationId: string | number;
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
sites: LocationOption[];
|
||||
createMode: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
export function useSiteDialogState({
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
locationId,
|
||||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
sites,
|
||||
createMode,
|
||||
onSave,
|
||||
}: UseSiteDialogStateArgs) {
|
||||
const [code, setCode] = useState(value);
|
||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
||||
const [pn, setPn] = useState(pocName);
|
||||
const [pp, setPp] = useState(pocPhone);
|
||||
const [notes, setNotes] = useState(pocNotes);
|
||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
const {
|
||||
data: locationDetail,
|
||||
isLoading: locationDetailLoading,
|
||||
isError: locationDetailError,
|
||||
} = useLocationDetail(open && locId ? locId : undefined);
|
||||
|
||||
const selected = sites.find((s) => String(s.id) === locId);
|
||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||
const siteMissing = !locId;
|
||||
const pocMissing = !pn.trim() || !pp.trim();
|
||||
const canConfirm = !siteMissing && (!createMode || !pocMissing);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveLocationId(locationId, value, sites);
|
||||
setCode(value);
|
||||
setLocId(resolved);
|
||||
setPn(pocName);
|
||||
setPp(pocPhone);
|
||||
setNotes(pocNotes);
|
||||
setShowErrors(false);
|
||||
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
return;
|
||||
}
|
||||
// Only autofill empty fields so typed createMode POC edits are not overwritten.
|
||||
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
||||
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
||||
setPocFilledFor(locId);
|
||||
}, [open, locId, locationDetail, pocFilledFor]);
|
||||
|
||||
const handlePick = (id: string) => {
|
||||
const next = sites.find((s) => String(s.id) === id);
|
||||
const siteChanged = id !== locId;
|
||||
setLocId(id);
|
||||
if (next) {
|
||||
setCode(resolveSiteCode(next));
|
||||
}
|
||||
if (siteChanged) {
|
||||
setPn("");
|
||||
setPp("");
|
||||
setPocFilledFor("");
|
||||
} else if (id !== pocFilledFor) {
|
||||
setPocFilledFor("");
|
||||
}
|
||||
};
|
||||
|
||||
const attemptSave = () => {
|
||||
if (createMode && !canConfirm) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
site: code || (selected ? resolveSiteCode(selected) : ""),
|
||||
location: selected ? extractLocationLabel(selected) : "",
|
||||
locationId: locId,
|
||||
pocName: pn,
|
||||
pocPhone: pp,
|
||||
pocNotes: notes,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return {
|
||||
locId,
|
||||
pn,
|
||||
pp,
|
||||
notes,
|
||||
showErrors,
|
||||
locationDetail,
|
||||
locationDetailLoading,
|
||||
locationDetailError,
|
||||
selected,
|
||||
addressPreview,
|
||||
siteMissing,
|
||||
canConfirm,
|
||||
code,
|
||||
handlePick,
|
||||
attemptSave,
|
||||
setPn,
|
||||
setPp,
|
||||
setNotes,
|
||||
close: () => onOpenChange(false),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type InlineCreateFieldProps = {
|
||||
showError?: boolean;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** Bordered create-cell wrapper; error ring + helper reveal after a failed Save attempt. */
|
||||
export function InlineCreateField({ showError = false, error, children }: InlineCreateFieldProps) {
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1">
|
||||
<div
|
||||
className="flex w-full min-w-0 items-center"
|
||||
style={{
|
||||
minHeight: 34,
|
||||
borderRadius: 6,
|
||||
padding: "0 8px",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: `1px solid ${showError ? "var(--destructive)" : "var(--color-border)"}`,
|
||||
boxShadow: showError ? "0 0 0 1px var(--destructive)" : "none",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Text as="p" variant="error" when={Boolean(showError && error)} className="m-0 text-[11px]">
|
||||
{error}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import type { RefObject } from "react";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
import { SiteCell } from "./cells/site-cell";
|
||||
import { InlineCreateField } from "./inline-create-field";
|
||||
|
||||
type WoTableInlineIdentityCellsProps = {
|
||||
draft: WorkOrderTableDraft;
|
||||
sites: LocationOption[];
|
||||
sitePlace?: { city: string; state: string };
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
siteCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
woCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
siteShowError: boolean;
|
||||
siteError: string;
|
||||
woShowError: boolean;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
};
|
||||
|
||||
export function WoTableInlineIdentityCells({
|
||||
draft,
|
||||
sites,
|
||||
sitePlace,
|
||||
allRows,
|
||||
siteCellRef,
|
||||
woCellRef,
|
||||
siteShowError,
|
||||
siteError,
|
||||
woShowError,
|
||||
onPatch,
|
||||
}: WoTableInlineIdentityCellsProps) {
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
ref={siteCellRef}
|
||||
className="group/cell"
|
||||
style={{ padding: "8px 6px 0", verticalAlign: "top" }}
|
||||
>
|
||||
<InlineCreateField showError={siteShowError} error={siteError}>
|
||||
<SiteCell
|
||||
value={draft.site}
|
||||
location={draft.location}
|
||||
locationId={draft.locationId}
|
||||
city={sitePlace?.city}
|
||||
state={sitePlace?.state}
|
||||
pocName={draft.pocName}
|
||||
pocPhone={draft.pocPhone}
|
||||
pocNotes={draft.pocNotes}
|
||||
sites={sites}
|
||||
createMode
|
||||
onSave={(p) =>
|
||||
onPatch({
|
||||
site: p.site,
|
||||
location: p.location,
|
||||
locationId: p.locationId,
|
||||
pocName: p.pocName,
|
||||
pocPhone: p.pocPhone,
|
||||
pocNotes: p.pocNotes,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineCreateField>
|
||||
</td>
|
||||
|
||||
<td
|
||||
ref={woCellRef}
|
||||
style={{ padding: "8px 6px 0", verticalAlign: "top", whiteSpace: "nowrap" }}
|
||||
>
|
||||
<InlineCreateField showError={woShowError} error="Work order number is required">
|
||||
<EditableWONumberCell
|
||||
value={draft.woNumber}
|
||||
rescheduleCount={0}
|
||||
currentId="draft"
|
||||
allOrders={allRows as never[]}
|
||||
onSave={(v) => onPatch({ woNumber: v })}
|
||||
/>
|
||||
</InlineCreateField>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { Check, X } from "lucide-react";
|
|||
type WoTableInlineRowActionsProps = {
|
||||
canSave: boolean;
|
||||
saving: boolean;
|
||||
saveHint: string;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
|
@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
|
|||
export function WoTableInlineRowActions({
|
||||
canSave,
|
||||
saving,
|
||||
saveHint,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: WoTableInlineRowActionsProps) {
|
||||
|
|
@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
|
||||
disabled={saving}
|
||||
title={saveHint}
|
||||
aria-label={saving ? "Saving work order" : "Save work order"}
|
||||
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
...(canSave || saving ? {} : { opacity: 0.5, cursor: "not-allowed" }),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (canSave) {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { RefObject } from "react";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
|
|
@ -8,13 +9,12 @@ import { AssignedToCell } from "./cells/assigned-to-cell";
|
|||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import { SiteCell } from "./cells/site-cell";
|
||||
import { StatusCell } from "./cells/status-cell";
|
||||
import { TypeCell } from "./cells/type-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
import { toInlineDraftRow } from "./to-inline-draft-row";
|
||||
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
|
||||
|
||||
type WoTableInlineRowCellsProps = {
|
||||
draft: WorkOrderTableDraft;
|
||||
|
|
@ -25,6 +25,11 @@ type WoTableInlineRowCellsProps = {
|
|||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
siteCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
woCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
siteShowError: boolean;
|
||||
siteError: string;
|
||||
woShowError: boolean;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
};
|
||||
|
||||
|
|
@ -37,45 +42,30 @@ export function WoTableInlineRowCells({
|
|||
vendors,
|
||||
pmTypes,
|
||||
allRows,
|
||||
siteCellRef,
|
||||
woCellRef,
|
||||
siteShowError,
|
||||
siteError,
|
||||
woShowError,
|
||||
onPatch,
|
||||
}: WoTableInlineRowCellsProps) {
|
||||
const draftRow = toInlineDraftRow(draft);
|
||||
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<SiteCell
|
||||
value={draft.site}
|
||||
location={draft.location}
|
||||
locationId={draft.locationId}
|
||||
city={sitePlace?.city}
|
||||
state={sitePlace?.state}
|
||||
pocName={draft.pocName}
|
||||
pocPhone={draft.pocPhone}
|
||||
pocNotes={draft.pocNotes}
|
||||
sites={sites}
|
||||
onSave={(p) =>
|
||||
onPatch({
|
||||
site: p.site,
|
||||
location: p.location,
|
||||
locationId: p.locationId,
|
||||
pocName: p.pocName,
|
||||
pocPhone: p.pocPhone,
|
||||
pocNotes: p.pocNotes,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td style={{ padding: "0 14px" }}>
|
||||
<EditableWONumberCell
|
||||
value={draft.woNumber}
|
||||
rescheduleCount={0}
|
||||
currentId="draft"
|
||||
allOrders={allRows as never[]}
|
||||
onSave={(v) => onPatch({ woNumber: v })}
|
||||
/>
|
||||
</td>
|
||||
<WoTableInlineIdentityCells
|
||||
draft={draft}
|
||||
sites={sites}
|
||||
sitePlace={sitePlace}
|
||||
allRows={allRows}
|
||||
siteCellRef={siteCellRef}
|
||||
woCellRef={woCellRef}
|
||||
siteShowError={siteShowError}
|
||||
siteError={siteError}
|
||||
woShowError={woShowError}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
||||
|
|
@ -98,11 +88,15 @@ export function WoTableInlineRowCells({
|
|||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={weekDefault ? "week" : "date"}
|
||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
||||
onSaveWeek={(week) =>
|
||||
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Flag } from "lucide-react";
|
||||
|
||||
import type {
|
||||
|
|
@ -5,10 +6,14 @@ import type {
|
|||
VendorDropdownItem,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
|
||||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
draftHasScheduleDate,
|
||||
getInlineCreateMissing,
|
||||
inlineCreateSaveHint,
|
||||
inlineCreateSiteError,
|
||||
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
||||
|
||||
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
||||
|
|
@ -16,72 +21,86 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
|||
|
||||
type WoTableInlineRowProps = {
|
||||
draft: WorkOrderTableDraft;
|
||||
|
||||
draftDayKey: string;
|
||||
|
||||
users: WorkOrderUser[];
|
||||
|
||||
sites: LocationOption[];
|
||||
|
||||
sitePlace?: { city: string; state: string };
|
||||
|
||||
vendors: VendorDropdownItem[];
|
||||
|
||||
pmTypes: string[];
|
||||
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
|
||||
saving?: boolean;
|
||||
|
||||
onChange: (draft: WorkOrderTableDraft) => void;
|
||||
|
||||
onSave: () => void;
|
||||
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]);
|
||||
|
||||
function withScheduleStatusSync(next: WorkOrderTableDraft): WorkOrderTableDraft {
|
||||
const dated = draftHasScheduleDate(next);
|
||||
if (dated && PRE_SCHEDULE.has(next.status)) {
|
||||
return { ...next, status: "Scheduled" };
|
||||
}
|
||||
if (!dated && next.status === "Scheduled") {
|
||||
return { ...next, status: "Pending" };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function WoTableInlineRow({
|
||||
draft,
|
||||
|
||||
draftDayKey,
|
||||
|
||||
users,
|
||||
|
||||
sites,
|
||||
|
||||
sitePlace,
|
||||
|
||||
vendors,
|
||||
|
||||
pmTypes,
|
||||
|
||||
allRows,
|
||||
|
||||
saving = false,
|
||||
|
||||
onChange,
|
||||
|
||||
onSave,
|
||||
|
||||
onCancel,
|
||||
}: WoTableInlineRowProps) {
|
||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||
const missing = getInlineCreateMissing(draft);
|
||||
const canSave = !saving && missing.length === 0;
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const siteCellRef = useRef<HTMLTableCellElement>(null);
|
||||
const woCellRef = useRef<HTMLTableCellElement>(null);
|
||||
|
||||
const canSave =
|
||||
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
|
||||
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
|
||||
const siteShowError = showErrors && siteCellMissing;
|
||||
const woShowError = showErrors && missing.includes("woNumber");
|
||||
|
||||
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
|
||||
const patch = (p: Partial<WorkOrderTableDraft>) =>
|
||||
onChange(withScheduleStatusSync({ ...draft, ...p }));
|
||||
|
||||
const focusCell = (r: RefObject<HTMLTableCellElement | null>) =>
|
||||
r.current?.querySelector<HTMLElement>("input, button, [tabindex]")?.focus();
|
||||
|
||||
const attemptSave = () => {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
if (canSave) {
|
||||
onSave();
|
||||
return;
|
||||
}
|
||||
setShowErrors(true);
|
||||
if (siteCellMissing) {
|
||||
focusCell(siteCellRef);
|
||||
} else if (missing.includes("woNumber")) {
|
||||
focusCell(woCellRef);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
aria-busy={saving || undefined}
|
||||
style={{
|
||||
height: WO_TABLE_ROW_HEIGHT,
|
||||
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
|
||||
borderLeft: "2px solid var(--color-primary)",
|
||||
|
||||
background: "var(--color-primary-tint)",
|
||||
opacity: saving ? 0.7 : 1,
|
||||
pointerEvents: saving ? "none" : undefined,
|
||||
|
|
@ -102,6 +121,11 @@ export function WoTableInlineRow({
|
|||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allRows={allRows}
|
||||
siteCellRef={siteCellRef}
|
||||
woCellRef={woCellRef}
|
||||
siteShowError={siteShowError}
|
||||
siteError={inlineCreateSiteError(missing)}
|
||||
woShowError={woShowError}
|
||||
onPatch={patch}
|
||||
/>
|
||||
|
||||
|
|
@ -109,7 +133,8 @@ export function WoTableInlineRow({
|
|||
<WoTableInlineRowActions
|
||||
canSave={canSave}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
saveHint={saving ? "Saving…" : inlineCreateSaveHint(missing)}
|
||||
onSave={attemptSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -49,12 +49,16 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
|||
const startDayDraft = useCallback(
|
||||
(key: string, scheduledIso?: string) => {
|
||||
setDraftDayKey(key);
|
||||
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
|
||||
if (key === UNSCHEDULED_DAY_KEY) {
|
||||
setDraft({ ...EMPTY_TABLE_DRAFT });
|
||||
return;
|
||||
}
|
||||
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
|
||||
setDraft({
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
scheduledOn,
|
||||
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
|
||||
targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
|
||||
status: scheduledOn ? "Scheduled" : "Incomplete",
|
||||
});
|
||||
},
|
||||
[weekMonday],
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ export function assignWizardScheduleFields(
|
|||
const isRange = draft.schedMode === "range";
|
||||
|
||||
if (!isRange && draft.scheduleWeekOnly) {
|
||||
payload.scheduleWeekOnly = true;
|
||||
|
||||
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
|
||||
|
||||
// Only emit week-mode flags when a concrete week exists (SH-109: undated create).
|
||||
if (week) {
|
||||
payload.scheduleWeekOnly = true;
|
||||
payload.targetWeek = week;
|
||||
}
|
||||
} else if (!isRange && draft.scheduledOn) {
|
||||
|
|
|
|||
62
src/domain/work-orders/utils/get-inline-create-missing.ts
Normal file
62
src/domain/work-orders/utils/get-inline-create-missing.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/** Required fields for board inline create (Schedule On is optional — SH-109). */
|
||||
export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
|
||||
|
||||
export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
|
||||
site: "Site",
|
||||
woNumber: "WO #",
|
||||
pocName: "POC name",
|
||||
pocPhone: "POC phone",
|
||||
};
|
||||
|
||||
export type InlineCreateDraftFields = {
|
||||
site?: string;
|
||||
locationId?: string | number;
|
||||
woNumber?: string;
|
||||
pocName?: string;
|
||||
pocPhone?: string;
|
||||
};
|
||||
|
||||
export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] {
|
||||
const miss: InlineCreateReqField[] = [];
|
||||
const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim());
|
||||
if (!hasSite) {
|
||||
miss.push("site");
|
||||
}
|
||||
if (!d.woNumber?.trim()) {
|
||||
miss.push("woNumber");
|
||||
}
|
||||
if (!d.pocName?.trim()) {
|
||||
miss.push("pocName");
|
||||
}
|
||||
if (!d.pocPhone?.trim()) {
|
||||
miss.push("pocPhone");
|
||||
}
|
||||
return miss;
|
||||
}
|
||||
|
||||
/** Tooltip for Save: "Missing: Site, WO #" or "Save" when ready. */
|
||||
export function inlineCreateSaveHint(miss: InlineCreateReqField[]): string {
|
||||
if (miss.length === 0) {
|
||||
return "Save";
|
||||
}
|
||||
return `Missing: ${miss.map((m) => INLINE_CREATE_REQ_LABELS[m]).join(", ")}`;
|
||||
}
|
||||
|
||||
export function inlineCreateSiteError(miss: InlineCreateReqField[]): string {
|
||||
if (miss.includes("site")) {
|
||||
return "Site is required";
|
||||
}
|
||||
return "Contact name and phone are required";
|
||||
}
|
||||
|
||||
/** Concrete calendar day/range — week-only does not count (design hasScheduleDate). */
|
||||
export function draftHasScheduleDate(d: {
|
||||
scheduledOn?: string;
|
||||
scheduleWeekOnly?: boolean;
|
||||
schedMode?: string;
|
||||
}): boolean {
|
||||
if (d.scheduleWeekOnly || d.schedMode === "week") {
|
||||
return false;
|
||||
}
|
||||
return Boolean(d.scheduledOn?.trim());
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildInlineDraftCreateInput } from "@/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input";
|
||||
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
describe("buildInlineDraftCreateInput", () => {
|
||||
it("defaults type to PM and omits schedule flags when undated", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
woNumber: "25001",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "",
|
||||
scheduledOn: "",
|
||||
};
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
|
||||
expect(input.type).toBe("PM");
|
||||
expect(input.scheduleWeekOnly).toBe(false);
|
||||
expect(input.scheduledOn).toBe("");
|
||||
expect(input.targetWeek).toBe("");
|
||||
|
||||
const payload = mapWizardDraftToBoardCreatePayload(input);
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
expect(payload.targetWeek).toBeUndefined();
|
||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||
expect(payload.woNumber).toBe("25001");
|
||||
expect(payload.pocName).toBe("Jane");
|
||||
});
|
||||
|
||||
it("keeps week-only schedule when targetWeek is set", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
woNumber: "25002",
|
||||
type: "Reactive" as const,
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "2026-06-22",
|
||||
};
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
const payload = mapWizardDraftToBoardCreatePayload(input);
|
||||
|
||||
expect(payload.scheduleWeekOnly).toBe(true);
|
||||
expect(payload.targetWeek).toBe("2026-06-22");
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
const detailById = new Map<string, Location>();
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||
useLocationDetail: (id: string | undefined) => ({
|
||||
data: id && detailById.has(id) ? detailById.get(id) : undefined,
|
||||
isLoading: Boolean(id && !detailById.has(id)),
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const SITES: LocationOption[] = [
|
||||
{ id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
|
||||
{ id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },
|
||||
];
|
||||
|
||||
function locationDetail(id: string, contact: string, phone: string): Location {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
contact,
|
||||
phone,
|
||||
address: "100 Main St",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zipCode: "75001",
|
||||
};
|
||||
}
|
||||
|
||||
function renderSiteDialogState() {
|
||||
return renderHook(() =>
|
||||
useSiteDialogState({
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
value: "",
|
||||
locationId: "",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
sites: SITES,
|
||||
createMode: true,
|
||||
onSave: vi.fn(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("useSiteDialogState POC autofill", () => {
|
||||
beforeEach(() => {
|
||||
detailById.clear();
|
||||
});
|
||||
|
||||
it("preserves POC typed before deferred location detail resolves", () => {
|
||||
const { result, rerender } = renderSiteDialogState();
|
||||
|
||||
act(() => {
|
||||
result.current.handlePick("loc-1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPn("Dispatcher Typed");
|
||||
result.current.setPp("999-000-1111");
|
||||
});
|
||||
|
||||
expect(result.current.pn).toBe("Dispatcher Typed");
|
||||
expect(result.current.pp).toBe("999-000-1111");
|
||||
|
||||
detailById.set("loc-1", locationDetail("loc-1", "Site Contact", "214-555-0111"));
|
||||
act(() => {
|
||||
rerender();
|
||||
});
|
||||
|
||||
expect(result.current.pn).toBe("Dispatcher Typed");
|
||||
expect(result.current.pp).toBe("999-000-1111");
|
||||
});
|
||||
|
||||
it("clears prior POC on site switch then autofills from the new site detail", () => {
|
||||
const { result, rerender } = renderSiteDialogState();
|
||||
|
||||
act(() => {
|
||||
result.current.handlePick("loc-1");
|
||||
});
|
||||
|
||||
detailById.set("loc-1", locationDetail("loc-1", "Contact A", "111-111-1111"));
|
||||
act(() => {
|
||||
rerender();
|
||||
});
|
||||
|
||||
expect(result.current.pn).toBe("Contact A");
|
||||
expect(result.current.pp).toBe("111-111-1111");
|
||||
|
||||
act(() => {
|
||||
result.current.handlePick("loc-2");
|
||||
});
|
||||
|
||||
expect(result.current.locId).toBe("loc-2");
|
||||
expect(result.current.pn).toBe("");
|
||||
expect(result.current.pp).toBe("");
|
||||
|
||||
detailById.set("loc-2", locationDetail("loc-2", "Contact B", "222-222-2222"));
|
||||
act(() => {
|
||||
rerender();
|
||||
});
|
||||
|
||||
expect(result.current.pn).toBe("Contact B");
|
||||
expect(result.current.pp).toBe("222-222-2222");
|
||||
});
|
||||
});
|
||||
|
|
@ -190,6 +190,21 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
|||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits week-mode flags when scheduleWeekOnly is set without a week", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
siteCode: "BK5",
|
||||
scheduleWeekOnly: true,
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
});
|
||||
|
||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||
expect(payload.targetWeek).toBeUndefined();
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
draftHasScheduleDate,
|
||||
getInlineCreateMissing,
|
||||
inlineCreateSaveHint,
|
||||
inlineCreateSiteError,
|
||||
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
||||
|
||||
describe("getInlineCreateMissing", () => {
|
||||
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
|
||||
expect(
|
||||
getInlineCreateMissing({
|
||||
site: "BK5",
|
||||
locationId: "12",
|
||||
woNumber: "25001",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts locationId alone as site presence", () => {
|
||||
expect(
|
||||
getInlineCreateMissing({
|
||||
site: "",
|
||||
locationId: 42,
|
||||
woNumber: "25001",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists missing POC fields without requiring schedule", () => {
|
||||
expect(
|
||||
getInlineCreateMissing({
|
||||
site: "BK5",
|
||||
woNumber: "25001",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
}),
|
||||
).toEqual(["pocName", "pocPhone"]);
|
||||
});
|
||||
|
||||
it("lists site and woNumber when empty", () => {
|
||||
expect(getInlineCreateMissing({})).toEqual(["site", "woNumber", "pocName", "pocPhone"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inlineCreateSaveHint", () => {
|
||||
it("returns Save when ready", () => {
|
||||
expect(inlineCreateSaveHint([])).toBe("Save");
|
||||
});
|
||||
|
||||
it("lists missing labels", () => {
|
||||
expect(inlineCreateSaveHint(["site", "woNumber"])).toBe("Missing: Site, WO #");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inlineCreateSiteError", () => {
|
||||
it("prefers site-required message when site is missing", () => {
|
||||
expect(inlineCreateSiteError(["site", "pocName"])).toBe("Site is required");
|
||||
});
|
||||
|
||||
it("surfaces contact message when only POC is missing", () => {
|
||||
expect(inlineCreateSiteError(["pocName", "pocPhone"])).toBe(
|
||||
"Contact name and phone are required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftHasScheduleDate", () => {
|
||||
it("is true for a concrete day", () => {
|
||||
expect(draftHasScheduleDate({ scheduledOn: "2026-06-25" })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for week-only", () => {
|
||||
expect(
|
||||
draftHasScheduleDate({ scheduledOn: "", scheduleWeekOnly: true, schedMode: "week" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue