diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index cfdcf7d7..ea7ebf00 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,39 +1,21 @@ -import { useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { Alert, Box, CircularProgress } from "@mui/material"; import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; -import { VendorEditorDialog } from "./_components/vendor-editor-dialog"; -import { useVendorEditor } from "./_components/use-vendor-editor"; -import { WorkOrderActivity } from "./_components/work-order-activity"; -import { - DispatchAccordion, - WorkOrderDetailsSection, - WorkOrderHeader, - WorkOrderSummaryAccordions, -} from "./_components/work-order-view-sections"; -import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { - useChangeWorkOrderAssignment, - useChangeWorkOrderStatus, -} from "@/domain/work-orders/use-cases/use-work-order-mutations"; -import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel"; +import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion"; +import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog"; +import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions"; +import { WorkOrderViewDetails } from "./_components/view/work-order-view-details"; +import { WorkOrderViewHeader } from "./_components/view/work-order-view-header"; +import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page"; export default function WorkOrderViewPage() { const { id } = useParams(); - const navigate = useNavigate(); const workOrderId = id ?? ""; + const view = useWorkOrderViewPage(workOrderId); - const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); - const { data: users = [] } = useWorkOrderUsers(); - const statusMutation = useChangeWorkOrderStatus(workOrderId); - const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); - const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() }); - - const [dispatchOpen, setDispatchOpen] = useState(false); - const [dispatchDetailId, setDispatchDetailId] = useState(null); - - if (isLoading) { + if (view.isLoading) { return ( @@ -41,11 +23,11 @@ export default function WorkOrderViewPage() { ); } - if (error || !wo) { + if (view.error || !view.wo) { return ( - {error instanceof Error ? error.message : "Work order not found."} + {view.error instanceof Error ? view.error.message : "Work order not found."} ); @@ -53,56 +35,67 @@ export default function WorkOrderViewPage() { return ( - statusMutation.mutate(status)} - onDispatch={() => setDispatchOpen(true)} - onEdit={() => navigate(`/workorders/edit/${id}`)} - onBack={() => navigate("/workorders")} - /> - assignmentMutation.mutate(payload)} - /> - - - void vendorEditor.open(event, vendorId, vendorName) - } - onViewDispatch={setDispatchDetailId} - /> - view.statusMutation.mutate(status)} + onOpenDispatch={() => view.setDispatchOpen(true)} /> - {dispatchOpen && ( + + view.assignmentMutation.mutate({ userId, userName })} + onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })} + /> + + + + + + + + {view.dispatchOpen && ( setDispatchOpen(false)} - onDispatched={() => void refetch()} + workOrder={view.wo} + onClose={() => view.setDispatchOpen(false)} + onDispatched={() => void view.refetch()} /> )} - {dispatchDetailId != null && ( + {view.dispatchDetailId != null && ( setDispatchDetailId(null)} - onUpdated={() => void refetch()} + onClose={() => view.setDispatchDetailId(null)} + onUpdated={() => void view.refetch()} /> )} - { + if (!view.vendorMutation.isPending) view.setVendorEditor(null); + }} + onSave={view.saveVendorEditor} + onEditorChange={view.setVendorEditor} /> ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key.ts b/src/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key.ts new file mode 100644 index 00000000..95a75a3b --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key.ts @@ -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; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor.ts b/src/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor.ts new file mode 100644 index 00000000..3357830c --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor.ts @@ -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(); + 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()); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts new file mode 100644 index 00000000..502714c6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts @@ -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; + selectedKey: string; + selectedVendor: VendorDropdownItem | undefined; + resolvedCompany: string; + anchorCompanyName: string; + tech: string; + phone: string; + createVendor: ReturnType; + 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 }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx index a627d8da..8cd6dca2 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx @@ -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 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(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 ( + + ); + } + + return ( +
+ + New technician for {companyName} + +
+ Technician Name + setContactName(e.target.value)} + placeholder="Full name" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +
+
+ Technician Phone + setPhone(e.target.value)} + placeholder="(000) 000-0000" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +
+ {Boolean(error) && ( + + {error} + + )} +
+ + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx new file mode 100644 index 00000000..5b58de5e --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx @@ -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 ( + <> +
+
+ Technician + + } + searchPlaceholder="Search technician…" + options={technicianOptions.map((option) => ({ + key: option.key, + label: option.label, + }))} + onPick={onPick} + /> + {companyVendorForAdd != null && ( +
+ +
+ )} +
+ + {Boolean(tech) && ( +
+ Technician Phone + onPhoneChange(e.target.value)} + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +
+ )} + + {Boolean(resolvedCompany) && ( +
+ Company + +
+ )} + +
+ Notes +