Compare commits

...

2 commits

Author SHA1 Message Date
Arthur Bassi
a30ac73d29
feat(work-orders): draft technician until vendor dialog save (SH-193) (#89)
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
* docs(work-orders): add pending BE contract checklist for design parity gaps

* feat(work-orders): add technician affordance in wo vendor dialog

* fix(work-orders): defer technician create until vendor dialog save

* docs(work-orders): align pr description with jira and qa residuals

* fix(vendors): harden draft technician optional field typing

* refactor(workorders): extract vendor dialog body footer and save hook

* fix(work-orders): coerce null company vendor for add-technician prop

* fix(work-orders): preserve draft technician staging on save [SH-193]

* fix(work-orders): select technicians by stable id [SH-193]

Keep selectedKey as explicit dialog state (SiteDialog pattern) so same-name drafts do not collide with existing vendors, and cover the regression with interaction tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-11 14:01:31 -03:00
Arthur Bassi
9c7b42e96b
feat(work-orders): SH-132 FE — Due Date distinct from Schedule On + Emergency calendar modal (#82)
* feat(work-orders): align Due Date and Past Due with SH-132 FE contract [SH-132]

* fix(work-orders): highlight Schedule On for unscheduled rows

Restore forceScheduleHighlight for Unscheduled section rows while keeping Due Date highlight for past-due and attempt feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(work-orders): cover unscheduled Schedule On highlight [SH-132]

Add row-level public-interface regression proving Unscheduled rows force-highlight Schedule On, not Due Date (SH-165).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-11 13:49:41 -03:00
34 changed files with 1597 additions and 127 deletions

View file

@ -0,0 +1,56 @@
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
type DueDateCalendarDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
onSave: (iso: string) => void;
};
/**
* Emergency Due Date picker — dedicated calendar Dialog (no time, no week/range).
* Reuses the shared MiniCalendar used by the standard Due Date popover.
*/
export function DueDateCalendarDialog({
open,
onOpenChange,
value,
onSave,
}: DueDateCalendarDialogProps) {
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 320 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-5" onClick={(e) => e.stopPropagation()}>
<DialogTitle
className="mb-3 p-0"
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
>
Due Date
</DialogTitle>
<div className="flex justify-center">
<MiniCalendar
value={value}
onChange={(iso) => {
onSave(iso);
onOpenChange(false);
}}
/>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
type DueDateCellProps = {
type: WOType | "";
value: string;
q?: string;
highlight?: boolean;
onSave: (date: string) => void;
};
/**
* Board / slide-over Due Date editor.
* Emergency → dedicated calendar Dialog; other types → standard DateCell popover.
*/
export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellProps) {
const [open, setOpen] = useState(false);
if (type !== "Emergency") {
return <DateCell value={value} q={q} highlight={highlight} onSave={onSave} />;
}
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
style={
highlight
? {
boxShadow: "0 0 0 2px var(--color-accent)",
background: "var(--color-primary-tint)",
padding: "2px 8px",
margin: "0 -8px",
}
: undefined
}
aria-haspopup="dialog"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</span>
<ChevronDown
size={14}
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
style={{ color: "var(--muted-foreground)" }}
/>
</button>
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onSave} />
</>
);
}

View file

@ -0,0 +1,36 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
/**
* Board rows often have tech/company but no vendorId — resolve via vendors list.
* Prefers a stable vendor id over name+company matching.
*/
export function resolveTechnicianKey(
vendorId: string | number,
tech: string,
company: string,
vendors: VendorDropdownItem[],
): string {
const idStr = vendorId !== "" && vendorId != null ? String(vendorId) : "";
if (idStr && vendors.some((vendor) => String(vendor.id) === idStr)) {
return idStr;
}
if (idStr && vendors.length === 0) {
return idStr;
}
const name = tech.trim();
if (!name) {
return idStr;
}
const byNameAndCompany =
vendors.find((vendor) => vendor.contactName === name && vendor.companyName === company) ??
vendors.find((vendor) => vendor.contactName === name);
if (byNameAndCompany) {
return String(byNameAndCompany.id);
}
// Orphan tech string used as option key when no vendor row matches (legacy board cells).
return idStr || name;
}

View file

@ -0,0 +1,53 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
type ResolveVendorCompanyAnchorInput = {
company: string;
resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined;
vendors: VendorDropdownItem[];
};
export function resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors,
}: ResolveVendorCompanyAnchorInput): {
companyName: string;
companyVendor: VendorDropdownItem | null;
} {
const companyName = resolvedCompany || company;
if (!companyName) {
return { companyName: "", companyVendor: null };
}
const companyVendor =
selectedVendor ??
vendors.find((vendor) => vendor.companyName === companyName) ??
({
id: "",
companyName,
contactName: "",
tradeSpecialties: "",
address: "",
distanceMiles: null,
} satisfies VendorDropdownItem);
return { companyName, companyVendor };
}
export function mergeVendorDropdownItems(
vendors: VendorDropdownItem[],
addedVendors: VendorDropdownItem[],
): VendorDropdownItem[] {
const byId = new Map<string, VendorDropdownItem>();
for (const vendor of vendors) {
if (vendor.id === "") continue;
byId.set(String(vendor.id), vendor);
}
for (const vendor of addedVendors) {
if (vendor.id === "") continue;
byId.set(String(vendor.id), vendor);
}
return Array.from(byId.values());
}

View file

@ -49,7 +49,7 @@ export function StatusCell({
setAnchorEl(e.currentTarget);
}}
title="This WO missed its service window. Reschedule to unlock status updates."
title="This WO missed its deadline. Update the Due Date to unlock status updates."
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
@ -83,8 +83,7 @@ export function StatusCell({
color: "var(--status-pending-text)",
}}
>
This work order is past due. A new date must be scheduled before the status can be
updated.
This work order is past due. Update the Due Date before the status can be updated.
</Text>
<button
@ -98,7 +97,7 @@ export function StatusCell({
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Reschedule →
Update Due Date →
</button>
</div>
</div>

View file

@ -0,0 +1,75 @@
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
type UseVendorDialogSaveArgs = {
allVendors: VendorDropdownItem[];
pendingCreates: Record<string, VendorFormValues>;
selectedKey: string;
selectedVendor: VendorDropdownItem | undefined;
resolvedCompany: string;
anchorCompanyName: string;
tech: string;
phone: string;
createVendor: ReturnType<typeof useCreateVendor>;
onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void;
};
export function useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech,
phone,
createVendor,
onSave,
onOpenChange,
setSaveError,
}: UseVendorDialogSaveArgs) {
const resolveSaveVendor = () =>
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
allVendors.find(
(vendor) =>
vendor.contactName === tech &&
vendor.companyName === (resolvedCompany || anchorCompanyName),
) ??
selectedVendor;
const handleSave = async () => {
setSaveError(null);
let saveVendor = resolveSaveVendor();
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
try {
const created = await createVendor.mutateAsync({
...pendingPayload,
phone: phone.trim() || pendingPayload.phone,
});
saveVendor = vendorToDropdownItem(created);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create technician";
setSaveError(message);
return;
}
}
onSave({
vendorId: saveVendor ? String(saveVendor.id) : "",
company: resolvedCompany || anchorCompanyName,
tech: saveVendor?.contactName ?? tech,
techPhone: phone,
});
onOpenChange(false);
};
return { handleSave };
}

View file

@ -5,6 +5,7 @@ import { HoverChevron } from "./hover-chevron";
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
type VendorCellProps = {
vendorId?: string | number;
company: string;
tech: string;
techPhone?: string;
@ -13,7 +14,15 @@ type VendorCellProps = {
onSave: (patch: VendorPatch) => void;
};
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
export function VendorCell({
vendorId = "",
company,
tech,
techPhone,
vendors,
q,
onSave,
}: VendorCellProps) {
const [open, setOpen] = useState(false);
const hasAny = !!(tech || company);
@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
<VendorDialog
open={open}
onOpenChange={setOpen}
vendorId={vendorId}
company={company}
tech={tech}
techPhone={techPhone}

View file

@ -0,0 +1,134 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
import { createDraftVendorDropdownItem } from "@/domain/vendors/utils/draft-vendor";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
type VendorDialogAddTechnicianProps = {
companyName: string;
companyVendor: VendorDropdownItem;
onCreated: (vendor: VendorDropdownItem, phone: string, payload: VendorFormValues) => void;
};
export function VendorDialogAddTechnician({
companyName,
companyVendor,
onCreated,
}: VendorDialogAddTechnicianProps) {
const [expanded, setExpanded] = useState(false);
const [contactName, setContactName] = useState("");
const [phone, setPhone] = useState("");
const [error, setError] = useState<string | null>(null);
const resetForm = () => {
setContactName("");
setPhone("");
setError(null);
};
const handleAdd = () => {
const payload = buildTechnicianCreatePayload({
companyVendor,
contactName,
phone,
});
const parsed = vendorSchema.safeParse(payload);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
const message =
fieldErrors.contactName?.[0] ??
fieldErrors.phone?.[0] ??
fieldErrors.companyName?.[0] ??
"Enter a valid technician name";
setError(message);
return;
}
setError(null);
const draftItem = createDraftVendorDropdownItem(parsed.data);
onCreated(draftItem, parsed.data.phone || phone.trim(), parsed.data);
resetForm();
setExpanded(false);
};
if (!expanded) {
return (
<button
type="button"
onClick={() => setExpanded(true)}
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
style={{ color: "var(--color-primary)" }}
>
<Plus size={12} className="shrink-0" aria-hidden />+ Add technician
</button>
);
}
return (
<div
className="space-y-2 rounded-lg p-3"
style={{
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<Text as="p" variant="caption" sx={{ fontSize: 12, color: "var(--muted-foreground)" }}>
New technician for {companyName}
</Text>
<div>
<FieldLabel>Technician Name</FieldLabel>
<input
autoFocus
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="Full name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(000) 000-0000"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
{Boolean(error) && (
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
{error}
</Text>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => {
resetForm();
setExpanded(false);
}}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={contactName.trim() === ""}
onClick={handleAdd}
>
Add
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,121 @@
import { Wrench } from "lucide-react";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
type VendorDialogBodyProps = {
selectedKey: string;
technicianOptions: TechnicianOption[];
anchorCompanyName: string;
companyVendorForAdd: VendorDropdownItem | undefined;
tech: string;
phone: string;
notes: string;
resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined;
saveError: string | null;
onPick: (key: string) => void;
onPhoneChange: (phone: string) => void;
onNotesChange: (notes: string) => void;
onTechnicianCreated: (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => void;
};
export function VendorDialogBody({
selectedKey,
technicianOptions,
anchorCompanyName,
companyVendorForAdd,
tech,
phone,
notes,
resolvedCompany,
selectedVendor,
saveError,
onPick,
onPhoneChange,
onNotesChange,
onTechnicianCreated,
}: VendorDialogBodyProps) {
return (
<>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Technician</FieldLabel>
<WizardFieldSelect
value={selectedKey}
placeholder="Search technician …"
icon={
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={onPick}
/>
{companyVendorForAdd != null && (
<div className="mt-2">
<VendorDialogAddTechnician
companyName={anchorCompanyName}
companyVendor={companyVendorForAdd}
onCreated={onTechnicianCreated}
/>
</div>
)}
</div>
{Boolean(tech) && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => onPhoneChange(e.target.value)}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
)}
{Boolean(resolvedCompany) && (
<div>
<FieldLabel>Company</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div>
<FieldLabel>Notes</FieldLabel>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Contact preferences, availability, etc."
rows={3}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</div>
{Boolean(saveError) && (
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
{saveError}
</Text>
)}
</>
);
}

View file

@ -0,0 +1,29 @@
import { Button } from "@/components/ui/button";
type VendorDialogFooterProps = {
isPending: boolean;
onCancel: () => void;
onSave: () => void;
};
export function VendorDialogFooter({ isPending, onCancel, onSave }: VendorDialogFooterProps) {
return (
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
disabled={isPending}
onClick={onCancel}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={isPending}
onClick={() => void onSave()}
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
);
}

View file

@ -0,0 +1,6 @@
export type VendorPatch = {
vendorId: string;
company: string;
tech: string;
techPhone: string;
};

View file

@ -1,32 +1,30 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { Wrench } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import {
buildTechnicianOptions,
phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
export type VendorPatch = {
vendorId: string;
company: string;
tech: string;
techPhone: string;
};
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
type VendorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
vendorId?: string | number;
company: string;
tech: string;
techPhone?: string;
@ -37,43 +35,94 @@ type VendorDialogProps = {
export function VendorDialog({
open,
onOpenChange,
vendorId = "",
company,
tech,
techPhone = "",
vendors,
onSave,
}: VendorDialogProps) {
const createVendor = useCreateVendor();
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
);
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const allVendors = useMemo(
() => mergeVendorDropdownItems(vendors, addedVendors),
[vendors, addedVendors],
);
const technicianOptions = useMemo(
() => buildTechnicianOptions(vendors, tech, company),
[vendors, tech, company],
() => buildTechnicianOptions(allVendors, tech, company),
[allVendors, tech, company],
);
useEffect(() => {
if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
setSelectedKey(key);
setT(tech);
setPhone(techPhone || phoneForOption(tech, technicianOptions));
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
setNotes("");
setAddedVendors([]);
setPendingCreates({});
setSaveError(null);
}
}, [open, tech, techPhone, technicianOptions]);
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
}, [open, tech, techPhone, vendors, company, vendorId]);
const selected =
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
technicianOptions.find((o) => o.name === t || o.key === t);
const selectedKey = selected?.key ?? "";
const resolvedCompany = t ? (selected?.company ?? company) : "";
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
const selected = technicianOptions.find((option) => option.key === selectedKey);
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
const selectedVendor =
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const handlePick = (key: string) => {
const option = technicianOptions.find((o) => o.key === key);
const name = option?.name ?? key;
setT(name);
setPhone(phoneForOption(name, technicianOptions));
setSelectedKey(key);
setT(option?.name ?? key);
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
};
const handleTechnicianCreated = (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => {
setAddedVendors((current) => [...current, created]);
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
setSelectedKey(String(created.id));
setT(created.contactName);
setPhone(createdPhone);
};
const { handleSave } = useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
onSave,
onOpenChange,
setSaveError,
});
return (
<Dialog
open={open}
@ -96,84 +145,28 @@ export function VendorDialog({
Search and select the technician
</Text>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Technician</FieldLabel>
<WizardFieldSelect
value={selectedKey}
placeholder="Search technician …"
icon={
<Wrench
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={handlePick}
/>
</div>
<VendorDialogBody
selectedKey={selectedKey}
technicianOptions={technicianOptions}
anchorCompanyName={anchorCompanyName}
companyVendorForAdd={companyVendor ?? undefined}
tech={selected?.name ?? t}
phone={phone}
notes={notes}
resolvedCompany={resolvedCompany}
selectedVendor={selectedVendor}
saveError={saveError}
onPick={handlePick}
onPhoneChange={setPhone}
onNotesChange={setNotes}
onTechnicianCreated={handleTechnicianCreated}
/>
{Boolean(t) && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
)}
{Boolean(t) && (
<div>
<FieldLabel>Company</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div>
<FieldLabel>Notes</FieldLabel>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Contact preferences, availability, etc."
rows={3}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</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)}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({
vendorId: selectedVendor ? String(selectedVendor.id) : "",
company: resolvedCompany,
tech: t,
techPhone: phone,
});
onOpenChange(false);
}}
>
Save
</Button>
</div>
<VendorDialogFooter
isPending={createVendor.isPending}
onCancel={() => onOpenChange(false)}
onSave={handleSave}
/>
</DialogContent>
</Dialog>
);

View file

@ -7,6 +7,7 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
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";
@ -106,7 +107,11 @@ export function WoTableInlineRowCells({
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
<DueDateCell
type={draft.type}
value={draft.dueDate}
onSave={(date) => onPatch({ dueDate: date })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
@ -129,6 +134,7 @@ export function WoTableInlineRowCells({
<td className="group/cell" style={{ padding: "0 14px" }}>
<VendorCell
vendorId={draft.vendorId}
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}

View file

@ -3,6 +3,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell";
import { TypeCell } from "./cells/type-cell";
@ -16,6 +17,7 @@ type WoTableRowCellsProps = {
sitePlace?: { city: string; state: string };
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
@ -29,6 +31,7 @@ export function WoTableRowCells({
sitePlace,
search,
forceScheduleHighlight,
forceDueDateHighlight,
mutationCellStyle,
handlers,
onPatchRow,
@ -104,7 +107,7 @@ export function WoTableRowCells({
<DateCell
value={row.scheduledOn}
q={search}
highlight={forceScheduleHighlight || row.isPastDue}
highlight={forceScheduleHighlight}
allowWeekMode
targetWeek={row.targetWeek}
onSave={(date) => handlers.onScheduleDate(row.id, date)}
@ -116,7 +119,13 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell value={row.dueDate} q={search} onSave={(date) => onPatchRow({ dueDate: date })} />
<DueDateCell
type={row.type}
value={row.dueDate}
q={search}
highlight={forceDueDateHighlight || row.isPastDue}
onSave={(date) => onPatchRow({ dueDate: date })}
/>
</td>
</>
);

View file

@ -56,6 +56,7 @@ export function WoTableRowServiceCells({
onClick={(e) => e.stopPropagation()}
>
<VendorCell
vendorId={row.vendorId}
company={row.company}
tech={row.tech}
techPhone={row.techPhone}

View file

@ -45,6 +45,7 @@ type WoTableRowProps = {
pmTypes: string[];
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
isSelected: boolean;
isFlashing: boolean;
dragViz: DragVizState;
@ -61,6 +62,7 @@ export function WoTableRow({
pmTypes,
search,
forceScheduleHighlight,
forceDueDateHighlight,
isSelected,
isFlashing,
dragViz,
@ -154,6 +156,7 @@ export function WoTableRow({
sitePlace={sitePlace}
search={search}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={forceDueDateHighlight}
mutationCellStyle={mutationCellStyle}
handlers={handlers}
onPatchRow={patchRow}

View file

@ -54,7 +54,7 @@ export function WoTable({
onOpenAdvancedFilters,
onPatch,
}: WoTableProps) {
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => {
@ -69,7 +69,7 @@ export function WoTable({
onOpenCompDoc,
onOpenMedia,
onPatch,
onScheduleHighlight: setScheduleHighlightId,
onScheduleHighlight: setDueDateHighlightId,
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -83,7 +83,8 @@ export function WoTable({
vendors={vendors}
pmTypes={pmTypes}
search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={dueDateHighlightId === row.id}
isSelected={tableData.selectedId === row.id}
isFlashing={tableData.flashId === row.id}
dragViz={tableData.dragViz}

View file

@ -0,0 +1,69 @@
import { useState } from "react";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils";
type WizardDueDateFieldProps = {
type: WOType | "";
value: string;
placeholder: string;
onChange: (iso: string) => void;
};
/**
* Wizard Due Date editor.
* Emergency → dedicated calendar Dialog; other types → WizardDateField popover.
*/
export function WizardDueDateField({
type,
value,
placeholder,
onChange,
}: WizardDueDateFieldProps) {
const [open, setOpen] = useState(false);
if (type !== "Emergency") {
return <WizardDateField value={value} placeholder={placeholder} onChange={onChange} />;
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-2 transition-colors"
style={{
height: 36,
border: "1px solid var(--color-border)",
borderRadius: 6,
background: "var(--color-bg-surface)",
padding: "0 10px",
outline: "none",
}}
onFocus={(e) => {
e.currentTarget.style.boxShadow = "0 0 0 2px var(--color-primary)";
}}
onBlur={(e) => {
e.currentTarget.style.boxShadow = "none";
}}
aria-haspopup="dialog"
>
<CalendarIcon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
<span
className="flex-1 truncate text-left"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
color: value ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{value ? fmtDate(value) : placeholder}
</span>
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</button>
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onChange} />
</>
);
}

View file

@ -3,6 +3,7 @@ import { Text } from "@/components/ui/text";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
@ -197,7 +198,8 @@ export function WizardStepTypeSchedule({
>
Due Date
</WizLabelRow>
<WizardDateField
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}

View file

@ -0,0 +1,36 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
type BuildTechnicianCreatePayloadInput = {
companyVendor: Pick<VendorDropdownItem, "companyName" | "email" | "address" | "tradeSpecialties">;
contactName: string;
phone?: string;
companyId?: string | number | null;
};
/** Builds a vendor create payload for a new technician under an existing company. */
export function buildTechnicianCreatePayload({
companyVendor,
contactName,
phone = "",
companyId = null,
}: BuildTechnicianCreatePayloadInput): VendorFormValues {
return {
companyName: companyVendor.companyName,
contactName: contactName.trim(),
email: companyVendor.email ?? "",
phone: phone.trim(),
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: companyVendor.address ?? "",
city: "",
state: "",
zip: "",
tradeSpecialties: companyVendor.tradeSpecialties ?? "",
isActive: true,
companyId,
preferredContact: "Phone",
};
}

View file

@ -0,0 +1,21 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
export const DRAFT_VENDOR_ID_PREFIX = "draft:";
export function isDraftVendorId(id: string | number | null | undefined): boolean {
return String(id ?? "").startsWith(DRAFT_VENDOR_ID_PREFIX);
}
export function createDraftVendorDropdownItem(payload: VendorFormValues): VendorDropdownItem {
const email = (payload.email ?? "").trim();
return {
id: `${DRAFT_VENDOR_ID_PREFIX}${crypto.randomUUID()}`,
companyName: payload.companyName,
contactName: payload.contactName,
tradeSpecialties: payload.tradeSpecialties ?? "",
address: payload.address ?? "",
distanceMiles: null,
email: email || undefined,
};
}

View file

@ -0,0 +1,15 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { Vendor } from "@/domain/vendors/types/vendor";
export function vendorToDropdownItem(vendor: Vendor): VendorDropdownItem {
const email = vendor.email.trim();
return {
id: vendor.id ?? "",
companyName: vendor.companyName,
contactName: vendor.contactName,
tradeSpecialties: vendor.tradeSpecialties,
address: vendor.address,
distanceMiles: null,
email: email || undefined,
};
}

View file

@ -87,7 +87,7 @@ function tryPastDueRule({ fieldKey, actionKey }: AuditActionRuleContext): string
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
if (!matches) return null;
return "WO marked as Past Due. Scheduled date has passed without completion.";
return "WO marked as Past Due. Due date has passed without completion.";
}
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {

View file

@ -12,22 +12,19 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
if (TERMINAL_STATUSES.has(row.status)) {
if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
return false;
}
const today = new Date().toISOString().slice(0, 10);
const date = row.scheduledOn || row.dueDate;
if (!date) {
return false;
}
return date < today;
return row.dueDate < today;
}
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
return {
...row,
// Prefer API board flag when true; otherwise recompute from scheduledOn/dueDate.
isPastDue: row.isPastDue || computeIsPastDue(row),
// Recompute from dueDate (same date-only rule as BE). Avoid sticky
// `apiTrue || compute` so a future Due Date clears Past Due immediately.
isPastDue: computeIsPastDue(row),
};
}

View file

@ -0,0 +1,62 @@
import { ThemeProvider } from "@mui/material";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
import { muiTheme } from "@/lib/theme/mui-theme";
function renderWithTheme(ui: React.ReactElement) {
return render(<ThemeProvider theme={muiTheme}>{ui}</ThemeProvider>);
}
describe("DueDateCalendarDialog", () => {
it("saves the selected day as ISO and closes", () => {
const onSave = vi.fn();
const onOpenChange = vi.fn();
renderWithTheme(
<DueDateCalendarDialog open onOpenChange={onOpenChange} value="2026-07-15" onSave={onSave} />,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Due Date")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "20" }));
expect(onSave).toHaveBeenCalledWith("2026-07-20");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("DueDateCell", () => {
it("opens a dialog for Emergency and keeps popover for PM", () => {
const onSave = vi.fn();
const { rerender } = renderWithTheme(
<DueDateCell type="Emergency" value="2026-07-15" onSave={onSave} />,
);
fireEvent.click(screen.getByRole("button", { name: /07-15|7\/15|15/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
rerender(
<ThemeProvider theme={muiTheme}>
<DueDateCell type="PM" value="2026-07-15" onSave={onSave} />
</ThemeProvider>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("WizardDueDateField", () => {
it("opens a dialog when type is Emergency", () => {
renderWithTheme(
<WizardDueDateField type="Emergency" value="" placeholder="No due date" onChange={vi.fn()} />,
);
fireEvent.click(screen.getByRole("button", { name: /No due date/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
const vendors: VendorDropdownItem[] = [
{
id: 10,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: 4.2,
email: "dispatch@gateway.test",
},
{
id: 11,
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: 4.2,
email: "dispatch@gateway.test",
},
];
describe("resolveTechnicianKey", () => {
it("prefers a stable vendorId when it exists in the list", () => {
expect(resolveTechnicianKey(11, "Jordan Lee", "Gateway Plumbing", vendors)).toBe("11");
});
it("falls back to name+company when vendorId is missing", () => {
expect(resolveTechnicianKey("", "Jordan Lee", "Gateway Plumbing", vendors)).toBe("10");
});
it("keeps orphan tech string as key when no vendor matches", () => {
expect(resolveTechnicianKey("", "Unknown Tech", "Gateway Plumbing", vendors)).toBe(
"Unknown Tech",
);
});
it("keeps vendorId when the vendors list is still empty", () => {
expect(resolveTechnicianKey(42, "Jordan Lee", "Gateway Plumbing", [])).toBe("42");
});
});

View file

@ -0,0 +1,51 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
import { renderWithProviders } from "@/test/test-utils";
const companyVendor: VendorDropdownItem = {
id: 10,
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: 4.2,
email: "dispatch@gateway.test",
};
describe("VendorDialogAddTechnician", () => {
it("stages a draft technician locally without calling the create API", () => {
const onCreated = vi.fn();
renderWithProviders(
<VendorDialogAddTechnician
companyName="Gateway Plumbing"
companyVendor={companyVendor}
onCreated={onCreated}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
fireEvent.change(screen.getByPlaceholderText("Full name"), {
target: { value: "Jordan Lee" },
});
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
target: { value: "3145550199" },
});
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
expect(onCreated).toHaveBeenCalledTimes(1);
const [draftVendor, phone, payload] = onCreated.mock.calls[0] ?? [];
expect(isDraftVendorId(draftVendor.id)).toBe(true);
expect(draftVendor.contactName).toBe("Jordan Lee");
expect(phone).toBeTruthy();
expect(payload).toEqual(
expect.objectContaining({
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
}),
);
});
});

View file

@ -0,0 +1,178 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
import type { Vendor } from "@/domain/vendors/types/vendor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
const mutateAsync = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
mutateAsync,
isPending: false,
}),
}));
const existingVendor: VendorDropdownItem = {
id: 10,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: 4.2,
email: "dispatch@gateway.test",
};
const createdVendor: Vendor = {
id: 99,
companyId: 10,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
email: "dispatch@gateway.test",
phone: "(314) 555-9999",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "1 Main St",
city: "",
state: "",
zip: "",
tradeSpecialties: "Plumbing",
isActive: true,
preferredContact: "Phone",
};
describe("VendorDialog stable technician identity", () => {
beforeEach(() => {
mutateAsync.mockReset();
mutateAsync.mockResolvedValue(createdVendor);
});
it("keeps draft identity on same-name collision and posts live phone on save", async () => {
const onSave = vi.fn();
const onOpenChange = vi.fn();
renderWithProviders(
<VendorDialog
open={true}
onOpenChange={onOpenChange}
vendorId={10}
company="Gateway Plumbing"
tech="Jordan Lee"
techPhone="(314) 555-0100"
vendors={[existingVendor]}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
fireEvent.change(screen.getByPlaceholderText("Full name"), {
target: { value: "Jordan Lee" },
});
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
target: { value: "3145550199" },
});
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
// Draft stays staged: add form collapses and select still shows the colliding name.
expect(screen.getByRole("button", { name: /\+ add technician/i })).toBeTruthy();
expect(screen.getByText("Jordan Lee · Gateway Plumbing")).toBeTruthy();
fireEvent.change(screen.getByDisplayValue("3145550199"), {
target: { value: "(314) 555-9999" },
});
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
expect(mutateAsync).toHaveBeenCalledTimes(1);
});
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Jordan Lee",
companyName: "Gateway Plumbing",
phone: "(314) 555-9999",
}),
);
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
vendorId: "99",
tech: "Jordan Lee",
company: "Gateway Plumbing",
techPhone: "(314) 555-9999",
}),
);
});
// Must not persist the pre-existing colliding vendor id.
expect(onSave.mock.calls[0]?.[0].vendorId).not.toBe("10");
});
it("does not clear staged draft when technicianOptions rebuild after add", async () => {
const onSave = vi.fn();
mutateAsync.mockResolvedValue({
...createdVendor,
id: 88,
contactName: "Casey New",
phone: "3145550188",
});
renderWithProviders(
<VendorDialog
open={true}
onOpenChange={vi.fn()}
vendorId={11}
company="Gateway Plumbing"
tech="Adam Whyte"
techPhone=""
vendors={[
existingVendor,
{
...existingVendor,
id: 11,
contactName: "Adam Whyte",
},
]}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
fireEvent.change(screen.getByPlaceholderText("Full name"), {
target: { value: "Casey New" },
});
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
target: { value: "3145550188" },
});
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
expect(screen.getByText("Casey New · Gateway Plumbing")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
expect(mutateAsync).toHaveBeenCalledTimes(1);
});
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Casey New",
phone: "3145550188",
}),
);
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
vendorId: "88",
tech: "Casey New",
}),
);
});
});
});

View file

@ -0,0 +1,118 @@
import { renderWithProviders } from "@/test/test-utils";
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const HIGHLIGHT_BOX_SHADOW = "0 0 0 2px var(--color-accent)";
const unscheduledRow: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "CHI-1042",
type: "PM",
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Chicago Loop",
locationId: "mock-loc-1",
pm: "HVAC — Quarterly PM",
serviceNotes: "",
extraServices: [],
vendorId: "",
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Pending",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "2026-07-15",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
};
const handlers: WoTableRowHandlers = {
onRowClick: vi.fn(),
onView: vi.fn(),
onEdit: vi.fn(),
onOpenMedia: vi.fn(),
onPatch: vi.fn(),
onScheduleDate: vi.fn(),
onScheduleWeek: vi.fn(),
onOpenSlideOver: vi.fn(),
onReorder: vi.fn(),
onDragStart: vi.fn(),
onDragOver: vi.fn(),
onDrop: vi.fn(),
onDragEnd: vi.fn(),
};
/** Mirrors WoTable.renderRow: body `true` → Schedule On only. */
function renderUnscheduledHarness() {
const tableData = {
advActive: false,
unscheduled: [unscheduledRow],
unschedOpen: true,
draftDayKey: null,
displayGroups: [],
todayKey: null,
startDayDraft: vi.fn(),
setUnschedOpen: vi.fn(),
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
<tr key={String(row.id)}>
<WoTableRowCells
row={row}
allRows={[row]}
users={[]}
sites={[]}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={false}
handlers={handlers}
onPatchRow={vi.fn()}
/>
</tr>
);
return renderWithProviders(
<table>
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
</table>,
{ withAuth: false },
);
}
describe("WoTableBody unscheduled Schedule On highlight (SH-165)", () => {
it("force-highlights Schedule On for Unscheduled rows, not Due Date", () => {
renderUnscheduledHarness();
const scheduleOn = screen.getByRole("button", { name: /^—$/ });
const dueDate = screen.getByRole("button", { name: /07-15/ });
expect(scheduleOn).toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
expect(dueDate).not.toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
});
});

View file

@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { Vendor } from "@/domain/vendors/types/vendor";
describe("buildTechnicianCreatePayload", () => {
it("copies company fields and sets the new technician identity", () => {
const payload = buildTechnicianCreatePayload({
companyVendor: {
companyName: "Gateway Plumbing",
email: "dispatch@gateway.test",
address: "1 Main St",
tradeSpecialties: "Plumbing",
},
contactName: "Jordan Lee",
phone: "(314) 555-0100",
companyId: 42,
});
expect(payload).toMatchObject({
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
phone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "1 Main St",
tradeSpecialties: "Plumbing",
companyId: 42,
isActive: true,
});
});
});
describe("resolveVendorCompanyAnchor", () => {
const vendors: VendorDropdownItem[] = [
{
id: 10,
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: null,
},
];
it("returns null anchor when no company context exists", () => {
expect(
resolveVendorCompanyAnchor({
company: "",
resolvedCompany: "",
selectedVendor: undefined,
vendors,
}),
).toEqual({ companyName: "", companyVendor: null });
});
it("merges newly created vendors without dropping existing options", () => {
const added: VendorDropdownItem = {
id: 55,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: null,
};
expect(mergeVendorDropdownItems(vendors, [added])).toHaveLength(2);
});
});
describe("vendorToDropdownItem", () => {
it("maps a created vendor into the work-order dropdown shape", () => {
const vendor: Vendor = {
id: 99,
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
email: "jordan@gateway.test",
phone: "(314) 555-0100",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "1 Main St",
city: "St. Louis",
state: "MO",
zip: "63101",
tradeSpecialties: "Plumbing",
isActive: true,
preferredContact: "Phone",
};
expect(vendorToDropdownItem(vendor)).toEqual({
id: 99,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: null,
email: "jordan@gateway.test",
});
});
});

View file

@ -124,6 +124,24 @@ describe("expandTablePatchToBoardFieldOps", () => {
}),
).toEqual([{ field: "siteCode", value: "BK5" }]);
});
it("keeps dueDate and scheduledDate patches independent", () => {
expect(expandTablePatchToBoardFieldOps({ dueDate: "2026-08-01" })).toEqual([
{ field: "dueDate", value: "2026-08-01" },
]);
expect(
expandTablePatchToBoardFieldOps({
scheduledOn: "2026-07-15",
scheduleWeekOnly: false,
}),
).toEqual([
{ field: "scheduleWeekOnly", value: "false" },
{ field: "scheduledDate", value: "2026-07-15" },
]);
expect(expandTablePatchToBoardFieldOps({ dueDate: "2026-08-01", isPastDue: false })).toEqual([
{ field: "dueDate", value: "2026-08-01" },
]);
});
});
describe("buildBoardPatchPayload", () => {

View file

@ -65,7 +65,7 @@ describe("formatBoardAuditAction", () => {
oldValue: "false",
newValue: "true",
}),
).toBe("WO marked as Past Due. Scheduled date has passed without completion.");
).toBe("WO marked as Past Due. Due date has passed without completion.");
});
it("falls back to from → to for unknown fields", () => {

View file

@ -3,6 +3,8 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import {
buildDayGroups,
computeIsPastDue,
enrichTableRow,
matchesAdvancedFilters,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
@ -10,6 +12,11 @@ import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import { mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
function shiftIso(days: number): string {
const d = new Date(`${todayIso()}T12:00:00.000Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: "1",
@ -123,6 +130,122 @@ describe("matchesAdvancedFilters", () => {
};
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
});
it("matches Overdue when isPastDue is true", () => {
const weekMonday = mondayOf(todayIso());
const pastDue = makeRow({
scheduledOn: weekMonday,
...deriveDayFields(weekMonday),
isPastDue: true,
});
const filters = {
...EMPTY_ADVANCED_FILTERS,
dateRange: "custom" as const,
customFrom: "0000-01-01",
customTo: "9999-12-31",
types: new Set(["Overdue" as const]),
};
expect(matchesAdvancedFilters(pastDue, filters)).toBe(true);
expect(
matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters),
).toBe(false);
});
});
describe("computeIsPastDue", () => {
it("is true when dueDate is before today even if scheduledOn is future", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: shiftIso(-1),
scheduledOn: shiftIso(5),
status: "Scheduled",
}),
),
).toBe(true);
});
it("is false when dueDate is future even if scheduledOn is past", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: shiftIso(5),
scheduledOn: shiftIso(-1),
status: "Incomplete",
}),
),
).toBe(false);
});
it("is false when dueDate is empty", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: "",
scheduledOn: shiftIso(-3),
status: "Scheduled",
}),
),
).toBe(false);
});
it("is false for terminal statuses even with past dueDate", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: shiftIso(-2),
scheduledOn: shiftIso(-2),
status: "Completed",
}),
),
).toBe(false);
expect(
computeIsPastDue(
makeRow({
dueDate: shiftIso(-2),
status: "Canceled",
}),
),
).toBe(false);
});
it("is false when dueDate is today", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: todayIso(),
scheduledOn: shiftIso(-1),
status: "Scheduled",
}),
),
).toBe(false);
});
});
describe("enrichTableRow", () => {
it("recomputes isPastDue from dueDate and ignores sticky API true", () => {
const enriched = enrichTableRow(
makeRow({
dueDate: shiftIso(5),
scheduledOn: shiftIso(-1),
isPastDue: true,
status: "Scheduled",
}),
);
expect(enriched.isPastDue).toBe(false);
});
it("sets isPastDue true from past dueDate even when API flag is false", () => {
const enriched = enrichTableRow(
makeRow({
dueDate: shiftIso(-1),
scheduledOn: shiftIso(5),
isPastDue: false,
status: "Incomplete",
}),
);
expect(enriched.isPastDue).toBe(true);
});
});
describe("mapWorkOrderTableRow", () => {

View file

@ -0,0 +1,27 @@
## Jira
- [SH-193](https://luby-us.atlassian.net/browse/SH-193)
## QA residual
- P0 done: technician is staged as a **local draft** until the vendor dialog is saved / wizard submitted — Cancel does not orphan a vendor row.
## Summary
- Adds **+ Add technician** to the WO board vendor dialog when a company context is known (from the row or the current selection).
- Stages a draft technician locally (`draft:` id) without calling `POST vendors` until the dialog save commits the selection.
- On save, create runs via the existing **`POST vendors`** REST endpoint (`vendorsApi.create` / `useCreateVendor`) using the company profile + new `contactName`.
## Backend
Uses the established vendor create API (`API_PATHS.rest.vendors` → `POST /vendors`). Each vendor record represents one technician; linking to an existing company is done by sending the same `companyName` (and optional `companyId` when available). **No new endpoint**.
## Test plan
- [ ] Open vendor dialog → **+ Add technician** → fill name → Cancel dialog → no vendor create API call.
- [ ] Add technician → Save dialog → `POST /vendors` once; row shows new tech.
- [ ] `npm run verify`
## Out of scope
- Multi-POC, multi-tech arrays on WO, vendors directory redesign.