mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 04:42:03 +00:00
fix(work-orders): extract inline dialog state and enable Schedule On clear
[recover] remove malicious eslint payload (was 552c2daf)
This commit is contained in:
parent
78ed8571a0
commit
ebc2e9a1e5
17 changed files with 709 additions and 189 deletions
|
|
@ -12,12 +12,15 @@ export function buildInlineDraftCreateInput(
|
||||||
const location = sites.find((s) => String(s.id) === String(draft.locationId));
|
const location = sites.find((s) => String(s.id) === String(draft.locationId));
|
||||||
const locationName = draft.location || draft.site || location?.name || "";
|
const locationName = draft.location || draft.site || location?.name || "";
|
||||||
|
|
||||||
|
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
|
||||||
|
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locationId: String(draft.locationId || ""),
|
locationId: String(draft.locationId || ""),
|
||||||
locationName,
|
locationName,
|
||||||
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||||
woNumber: draft.woNumber,
|
woNumber: draft.woNumber,
|
||||||
type: draft.type,
|
type: draft.type || "PM",
|
||||||
assigneeId: draft.dispatcherId,
|
assigneeId: draft.dispatcherId,
|
||||||
assigneeName: assignee?.name ?? "",
|
assigneeName: assignee?.name ?? "",
|
||||||
scheduledOn: draft.scheduledOn,
|
scheduledOn: draft.scheduledOn,
|
||||||
|
|
@ -35,7 +38,7 @@ export function buildInlineDraftCreateInput(
|
||||||
pocName: draft.pocName,
|
pocName: draft.pocName,
|
||||||
pocPhone: draft.pocPhone,
|
pocPhone: draft.pocPhone,
|
||||||
pocNotes: draft.pocNotes,
|
pocNotes: draft.pocNotes,
|
||||||
scheduleWeekOnly: draft.scheduleWeekOnly,
|
scheduleWeekOnly,
|
||||||
targetWeek: draft.targetWeek || "",
|
targetWeek: draft.targetWeek || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ type DateCellProps = {
|
||||||
targetWeek?: string;
|
targetWeek?: string;
|
||||||
onSaveWeek?: (weekStart: string) => void;
|
onSaveWeek?: (weekStart: string) => void;
|
||||||
defaultMode?: "week" | "date";
|
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({
|
export function DateCell({
|
||||||
|
|
@ -26,6 +28,7 @@ export function DateCell({
|
||||||
targetWeek = "",
|
targetWeek = "",
|
||||||
onSaveWeek,
|
onSaveWeek,
|
||||||
defaultMode,
|
defaultMode,
|
||||||
|
onClear,
|
||||||
}: DateCellProps) {
|
}: DateCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
const [cellMode, setCellMode] = useState<"week" | "date">(
|
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>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ type SiteCellProps = {
|
||||||
pocNotes?: string;
|
pocNotes?: string;
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
q?: string;
|
q?: string;
|
||||||
|
/** Inline create: editable POC + Site/POC required to confirm the modal. */
|
||||||
|
createMode?: boolean;
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -31,6 +33,7 @@ export function SiteCell({
|
||||||
pocNotes,
|
pocNotes,
|
||||||
sites,
|
sites,
|
||||||
q,
|
q,
|
||||||
|
createMode = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: SiteCellProps) {
|
}: SiteCellProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -70,7 +73,15 @@ export function SiteCell({
|
||||||
)}
|
)}
|
||||||
</span>
|
</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 />
|
<HoverChevron />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -83,6 +94,7 @@ export function SiteCell({
|
||||||
pocPhone={pocPhone}
|
pocPhone={pocPhone}
|
||||||
pocNotes={pocNotes}
|
pocNotes={pocNotes}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
|
createMode={createMode}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
|
||||||
pocName: string;
|
pocName: string;
|
||||||
pocPhone: string;
|
pocPhone: string;
|
||||||
pocNotes: 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 (
|
return (
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<FieldLabel>Point of Contact</FieldLabel>
|
<FieldLabel>Point of Contact</FieldLabel>
|
||||||
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
<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>
|
</Text>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<div>
|
||||||
value={pocName}
|
<input
|
||||||
readOnly
|
value={pocName}
|
||||||
placeholder="POC name"
|
readOnly={!editable}
|
||||||
className={DLG_INPUT_CLS}
|
placeholder="POC name"
|
||||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
className={DLG_INPUT_CLS}
|
||||||
/>
|
style={{
|
||||||
<input
|
...DLG_INPUT_STYLE,
|
||||||
value={pocPhone}
|
opacity: editable ? 1 : 0.85,
|
||||||
readOnly
|
borderColor: nameMissing ? "var(--destructive)" : undefined,
|
||||||
placeholder="POC phone"
|
}}
|
||||||
className={DLG_INPUT_CLS}
|
onChange={(e) => onPocNameChange?.(e.target.value)}
|
||||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
/>
|
||||||
/>
|
<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
|
<textarea
|
||||||
value={pocNotes}
|
value={pocNotes}
|
||||||
readOnly
|
readOnly={!editable}
|
||||||
placeholder="Notes…"
|
placeholder="Notes…"
|
||||||
rows={2}
|
rows={2}
|
||||||
className={`${DLG_INPUT_CLS} resize-none`}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,20 @@
|
||||||
import { useEffect, useState, type MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
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 type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
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 { 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 { 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 = {
|
export type { SitePatch };
|
||||||
site: string;
|
|
||||||
location: string;
|
|
||||||
locationId: string | number;
|
|
||||||
pocName: string;
|
|
||||||
pocPhone: string;
|
|
||||||
pocNotes: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SiteDialogProps = {
|
type SiteDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -35,6 +25,7 @@ type SiteDialogProps = {
|
||||||
pocPhone?: string;
|
pocPhone?: string;
|
||||||
pocNotes?: string;
|
pocNotes?: string;
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
|
createMode?: boolean;
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -47,62 +38,26 @@ export function SiteDialog({
|
||||||
pocPhone = "",
|
pocPhone = "",
|
||||||
pocNotes = "",
|
pocNotes = "",
|
||||||
sites,
|
sites,
|
||||||
|
createMode = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: SiteDialogProps) {
|
}: SiteDialogProps) {
|
||||||
const [code, setCode] = useState(value);
|
const s = useSiteDialogState({
|
||||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
open,
|
||||||
const [pn, setPn] = useState(pocName);
|
onOpenChange,
|
||||||
const [pp, setPp] = useState(pocPhone);
|
value,
|
||||||
const [notes, setNotes] = useState(pocNotes);
|
locationId,
|
||||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
pocName,
|
||||||
|
pocPhone,
|
||||||
const {
|
pocNotes,
|
||||||
data: locationDetail,
|
sites,
|
||||||
isLoading: locationDetailLoading,
|
createMode,
|
||||||
isError: locationDetailError,
|
onSave,
|
||||||
} = 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("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={s.close}
|
||||||
maxWidth={false}
|
maxWidth={false}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...dialogBlurBackdropSlotProps,
|
...dialogBlurBackdropSlotProps,
|
||||||
|
|
@ -125,7 +80,7 @@ export function SiteDialog({
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Site</FieldLabel>
|
<FieldLabel>Site</FieldLabel>
|
||||||
<WizardFieldSelect
|
<WizardFieldSelect
|
||||||
value={locId}
|
value={s.locId}
|
||||||
placeholder="Select site …"
|
placeholder="Select site …"
|
||||||
icon={
|
icon={
|
||||||
<MapPin
|
<MapPin
|
||||||
|
|
@ -139,45 +94,45 @@ export function SiteDialog({
|
||||||
key: String(site.id),
|
key: String(site.id),
|
||||||
label: site.name,
|
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>
|
</div>
|
||||||
|
|
||||||
{Boolean(locId) && (
|
{Boolean(s.locId) && (
|
||||||
<SiteDialogDetailPanel
|
<SiteDialogDetailPanel
|
||||||
locationDetail={locationDetail}
|
locationDetail={s.locationDetail}
|
||||||
locationDetailLoading={locationDetailLoading}
|
locationDetailLoading={s.locationDetailLoading}
|
||||||
locationDetailError={locationDetailError}
|
locationDetailError={s.locationDetailError}
|
||||||
addressPreview={addressPreview}
|
addressPreview={s.addressPreview}
|
||||||
fallbackLabel={selected?.name || code || "—"}
|
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>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
|
||||||
variant="ghost"
|
|
||||||
className="min-h-0 px-2 py-1 text-[12px]"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
|
||||||
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);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 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 = {
|
type WoTableInlineRowActionsProps = {
|
||||||
canSave: boolean;
|
canSave: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
saveHint: string;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
|
||||||
export function WoTableInlineRowActions({
|
export function WoTableInlineRowActions({
|
||||||
canSave,
|
canSave,
|
||||||
saving,
|
saving,
|
||||||
|
saveHint,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: WoTableInlineRowActionsProps) {
|
}: WoTableInlineRowActionsProps) {
|
||||||
|
|
@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={!canSave}
|
disabled={saving}
|
||||||
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
|
title={saveHint}
|
||||||
aria-label={saving ? "Saving work order" : "Save work order"}
|
aria-label={saving ? "Saving work order" : "Save work order"}
|
||||||
className="rounded p-1 transition-colors disabled:opacity-30"
|
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) => {
|
onMouseEnter={(e) => {
|
||||||
if (canSave) {
|
if (canSave) {
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
|
|
@ -7,13 +8,12 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
|
||||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
import { DateCell } from "./cells/date-cell";
|
import { DateCell } from "./cells/date-cell";
|
||||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
|
||||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
import { SiteCell } from "./cells/site-cell";
|
|
||||||
import { StatusCell } from "./cells/status-cell";
|
import { StatusCell } from "./cells/status-cell";
|
||||||
import { TypeCell } from "./cells/type-cell";
|
import { TypeCell } from "./cells/type-cell";
|
||||||
import { VendorCell } from "./cells/vendor-cell";
|
import { VendorCell } from "./cells/vendor-cell";
|
||||||
import { toInlineDraftRow } from "./to-inline-draft-row";
|
import { toInlineDraftRow } from "./to-inline-draft-row";
|
||||||
|
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
|
||||||
|
|
||||||
type WoTableInlineRowCellsProps = {
|
type WoTableInlineRowCellsProps = {
|
||||||
draft: WorkOrderTableDraft;
|
draft: WorkOrderTableDraft;
|
||||||
|
|
@ -24,6 +24,11 @@ type WoTableInlineRowCellsProps = {
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
allRows: { id: string | number; woNumber: 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;
|
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -36,45 +41,30 @@ export function WoTableInlineRowCells({
|
||||||
vendors,
|
vendors,
|
||||||
pmTypes,
|
pmTypes,
|
||||||
allRows,
|
allRows,
|
||||||
|
siteCellRef,
|
||||||
|
woCellRef,
|
||||||
|
siteShowError,
|
||||||
|
siteError,
|
||||||
|
woShowError,
|
||||||
onPatch,
|
onPatch,
|
||||||
}: WoTableInlineRowCellsProps) {
|
}: WoTableInlineRowCellsProps) {
|
||||||
const draftRow = toInlineDraftRow(draft);
|
const draftRow = toInlineDraftRow(draft);
|
||||||
|
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<WoTableInlineIdentityCells
|
||||||
<SiteCell
|
draft={draft}
|
||||||
value={draft.site}
|
sites={sites}
|
||||||
location={draft.location}
|
sitePlace={sitePlace}
|
||||||
locationId={draft.locationId}
|
allRows={allRows}
|
||||||
city={sitePlace?.city}
|
siteCellRef={siteCellRef}
|
||||||
state={sitePlace?.state}
|
woCellRef={woCellRef}
|
||||||
pocName={draft.pocName}
|
siteShowError={siteShowError}
|
||||||
pocPhone={draft.pocPhone}
|
siteError={siteError}
|
||||||
pocNotes={draft.pocNotes}
|
woShowError={woShowError}
|
||||||
sites={sites}
|
onPatch={onPatch}
|
||||||
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>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
||||||
|
|
@ -97,11 +87,15 @@ export function WoTableInlineRowCells({
|
||||||
allowWeekMode
|
allowWeekMode
|
||||||
targetWeek={draft.targetWeek}
|
targetWeek={draft.targetWeek}
|
||||||
defaultMode={weekDefault ? "week" : "date"}
|
defaultMode={weekDefault ? "week" : "date"}
|
||||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
|
||||||
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
||||||
onSaveWeek={(week) =>
|
onSaveWeek={(week) =>
|
||||||
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
||||||
}
|
}
|
||||||
|
onClear={
|
||||||
|
hasSchedule
|
||||||
|
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef, useState, type RefObject } from "react";
|
||||||
import { Flag } from "lucide-react";
|
import { Flag } from "lucide-react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -5,10 +6,14 @@ import type {
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
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 { 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 { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||||
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
||||||
|
|
@ -16,72 +21,86 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
||||||
|
|
||||||
type WoTableInlineRowProps = {
|
type WoTableInlineRowProps = {
|
||||||
draft: WorkOrderTableDraft;
|
draft: WorkOrderTableDraft;
|
||||||
|
|
||||||
draftDayKey: string;
|
draftDayKey: string;
|
||||||
|
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
|
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
|
|
||||||
sitePlace?: { city: string; state: string };
|
sitePlace?: { city: string; state: string };
|
||||||
|
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
|
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
|
|
||||||
allRows: { id: string | number; woNumber: string }[];
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
|
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
|
||||||
onChange: (draft: WorkOrderTableDraft) => void;
|
onChange: (draft: WorkOrderTableDraft) => void;
|
||||||
|
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
|
||||||
onCancel: () => 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({
|
export function WoTableInlineRow({
|
||||||
draft,
|
draft,
|
||||||
|
|
||||||
draftDayKey,
|
draftDayKey,
|
||||||
|
|
||||||
users,
|
users,
|
||||||
|
|
||||||
sites,
|
sites,
|
||||||
|
|
||||||
sitePlace,
|
sitePlace,
|
||||||
|
|
||||||
vendors,
|
vendors,
|
||||||
|
|
||||||
pmTypes,
|
pmTypes,
|
||||||
|
|
||||||
allRows,
|
allRows,
|
||||||
|
|
||||||
saving = false,
|
saving = false,
|
||||||
|
|
||||||
onChange,
|
onChange,
|
||||||
|
|
||||||
onSave,
|
onSave,
|
||||||
|
|
||||||
onCancel,
|
onCancel,
|
||||||
}: WoTableInlineRowProps) {
|
}: WoTableInlineRowProps) {
|
||||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
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 =
|
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
|
||||||
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
|
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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
aria-busy={saving || undefined}
|
aria-busy={saving || undefined}
|
||||||
style={{
|
style={{
|
||||||
height: WO_TABLE_ROW_HEIGHT,
|
height: WO_TABLE_ROW_HEIGHT,
|
||||||
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
|
||||||
borderLeft: "2px solid var(--color-primary)",
|
borderLeft: "2px solid var(--color-primary)",
|
||||||
|
|
||||||
background: "var(--color-primary-tint)",
|
background: "var(--color-primary-tint)",
|
||||||
opacity: saving ? 0.7 : 1,
|
opacity: saving ? 0.7 : 1,
|
||||||
pointerEvents: saving ? "none" : undefined,
|
pointerEvents: saving ? "none" : undefined,
|
||||||
|
|
@ -102,6 +121,11 @@ export function WoTableInlineRow({
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
allRows={allRows}
|
allRows={allRows}
|
||||||
|
siteCellRef={siteCellRef}
|
||||||
|
woCellRef={woCellRef}
|
||||||
|
siteShowError={siteShowError}
|
||||||
|
siteError={inlineCreateSiteError(missing)}
|
||||||
|
woShowError={woShowError}
|
||||||
onPatch={patch}
|
onPatch={patch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -109,7 +133,8 @@ export function WoTableInlineRow({
|
||||||
<WoTableInlineRowActions
|
<WoTableInlineRowActions
|
||||||
canSave={canSave}
|
canSave={canSave}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onSave={onSave}
|
saveHint={saving ? "Saving…" : inlineCreateSaveHint(missing)}
|
||||||
|
onSave={attemptSave}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,16 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||||
const startDayDraft = useCallback(
|
const startDayDraft = useCallback(
|
||||||
(key: string, scheduledIso?: string) => {
|
(key: string, scheduledIso?: string) => {
|
||||||
setDraftDayKey(key);
|
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);
|
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
|
||||||
setDraft({
|
setDraft({
|
||||||
...EMPTY_TABLE_DRAFT,
|
...EMPTY_TABLE_DRAFT,
|
||||||
scheduledOn,
|
scheduledOn,
|
||||||
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
|
status: scheduledOn ? "Scheduled" : "Incomplete",
|
||||||
targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[weekMonday],
|
[weekMonday],
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ export function assignWizardScheduleFields(
|
||||||
const isRange = draft.schedMode === "range";
|
const isRange = draft.schedMode === "range";
|
||||||
|
|
||||||
if (!isRange && draft.scheduleWeekOnly) {
|
if (!isRange && draft.scheduleWeekOnly) {
|
||||||
payload.scheduleWeekOnly = true;
|
|
||||||
|
|
||||||
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
|
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) {
|
if (week) {
|
||||||
|
payload.scheduleWeekOnly = true;
|
||||||
payload.targetWeek = week;
|
payload.targetWeek = week;
|
||||||
}
|
}
|
||||||
} else if (!isRange && draft.scheduledOn) {
|
} 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -190,6 +190,21 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
expect(payload.scheduledDate).toBeUndefined();
|
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", () => {
|
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
|
||||||
const payload = mapWizardDraftToBoardCreatePayload({
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...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