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:
Alexandre Brandizzi 2026-08-12 12:17:15 -03:00 • committed by GitHub
commit 489469d330
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 827 additions and 189 deletions

View file

@ -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 || "",
};
}

View file

@ -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>
</>
);

View file

@ -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}
/>
</>

View file

@ -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>

View file

@ -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>

View file

@ -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),
};
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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)";

View file

@ -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>

View file

@ -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>

View file

@ -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],

View file

@ -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) {

View 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());
}

View file

@ -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();
});
});

View file

@ -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");
});
});

View file

@ -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,

View file

@ -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);
});
});