From 3ea136ce3ca05459bd717ffffc1aae337e15af41 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:45:59 -0300 Subject: [PATCH] feat(work-orders): integrate board list, search and field patch API --- src/api/api-error.ts | 10 + src/api/api-paths.ts | 16 +- src/api/handle-api-response.ts | 8 +- .../list/filter-bar/dispatcher-filter.tsx | 27 +- .../filter-bar/work-orders-filter-bar.tsx | 3 + .../list/table/cells/date-cell.tsx | 1 - .../list/table/cells/dispatcher-pill.tsx | 2 +- .../list/table/cells/flag-cell.tsx | 14 +- .../list/table/cells/pm-type-cell.tsx | 147 ++++- .../list/table/cells/service-notes-dialog.tsx | 115 ++++ .../list/table/cells/site-cell.tsx | 17 +- .../list/table/cells/site-dialog.tsx | 190 +++--- .../list/table/cells/vendor-dialog.tsx | 83 ++- .../_components/list/table/pm-type-icons.ts | 119 +++- .../list/table/reschedule-notify.ts | 46 ++ .../list/table/reschedule-toast.tsx | 58 ++ .../list/table/site-place-lookup.ts | 47 ++ .../list/table/wo-table-inline-row.tsx | 55 +- .../_components/list/table/wo-table-row.tsx | 58 +- .../_components/list/table/wo-table.tsx | 106 +++- .../list/work-orders-list-header.tsx | 4 +- .../_hooks/use-work-orders-list-filters.ts | 68 +-- .../_hooks/use-work-orders-table-data.ts | 294 ++++++--- src/app/(protected)/workorders/index.tsx | 34 +- src/domain/locations/api/locations-api.ts | 10 + .../locations/mappers/location-mapper.ts | 72 ++- src/domain/locations/types/location.ts | 8 + .../locations/use-cases/use-location-sites.ts | 25 + src/domain/work-orders/api/work-orders-api.ts | 382 +++++++++--- .../work-orders/errors/board-patch-errors.ts | 23 + src/domain/work-orders/flag-colors.ts | 13 + .../mappers/work-order-board-detail-mapper.ts | 257 ++++++++ .../mappers/work-order-board-mapper.ts | 249 ++++++++ .../mappers/work-order-board-patch-mapper.ts | 162 +++++ .../work-orders/mappers/work-order-mapper.ts | 318 +++++++++- .../mappers/work-order-vendor-mapper.ts | 1 + .../mocks/dispatcher-filter-mock-data.ts | 45 -- .../mocks/is-wo-table-mock-enabled.ts | 10 - .../mocks/is-wo-wizard-mock-enabled.ts | 10 - .../work-orders/mocks/wo-table-mock-data.ts | 521 ---------------- .../work-orders/mocks/wo-wizard-mock-data.ts | 158 ----- .../work-orders/schemas/work-order-schema.ts | 2 + .../types/work-order-board-detail.ts | 167 +++++ .../work-orders/types/work-order-board.ts | 254 ++++++++ .../work-orders/types/work-order-common.ts | 4 + .../work-orders/types/work-order-detail.ts | 13 + .../work-orders/types/work-order-media.ts | 2 + .../work-orders/types/work-order-table-row.ts | 26 +- .../work-orders/types/work-order-wizard.ts | 34 +- .../use-create-work-order-from-wizard.ts | 26 +- .../use-cases/use-work-order-detail.ts | 30 +- .../use-cases/use-work-order-mutations.ts | 124 +++- .../use-work-order-table-mutations.ts | 125 ++-- .../use-cases/use-work-orders-list.ts | 33 +- .../work-orders/utils/board-query-params.ts | 169 ++++++ .../utils/board-row-order-storage.ts | 156 +++++ .../work-orders/utils/comment-ownership.ts | 11 + .../utils/dispatcher-avatar-color.ts | 35 ++ .../utils/format-board-audit-action.ts | 178 ++++++ .../work-orders/utils/service-notes-ui.ts | 16 + .../utils/should-notify-cross-week.ts | 9 + .../work-orders/utils/slide-over-draft.ts | 73 +++ .../work-orders/utils/table-filter-utils.ts | 31 +- .../utils/vendor-dropdown-label.ts | 22 + .../work-orders/utils/wizard-date-utils.ts | 21 + .../utils/work-order-media-limits.ts | 27 + src/infra/query-key/query-key.ts | 16 +- .../locations/api/locations-sites-api.test.ts | 153 +++++ .../work-orders/api/work-orders-api.test.ts | 572 +++++++++++++++++- .../mappers/vendor-dropdown-mapper.test.ts | 84 +++ .../mappers/wizard-draft-mapper.test.ts | 54 +- .../work-order-board-detail-mapper.test.ts | 208 +++++++ .../mappers/work-order-board-mapper.test.ts | 336 ++++++++++ .../work-order-board-patch-mapper.test.ts | 182 ++++++ .../mappers/work-order-form-payload.test.ts | 99 ++- .../mappers/work-order-mapper.test.ts | 54 ++ .../utils/board-query-params.test.ts | 160 +++++ .../utils/board-row-order-storage.test.ts | 118 ++++ .../utils/comment-ownership.test.ts | 35 ++ .../utils/format-board-audit-action.test.ts | 103 ++++ .../utils/service-notes-ui.test.ts | 28 + .../utils/should-notify-cross-week.test.ts | 22 + .../utils/slide-over-draft.test.ts | 96 +++ .../utils/table-filter-utils.test.ts | 16 +- .../utils/wizard-date-utils.test.ts | 31 + .../utils/work-order-media-limits.test.ts | 26 + 86 files changed, 6415 insertions(+), 1352 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/reschedule-notify.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/reschedule-toast.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/site-place-lookup.ts create mode 100644 src/domain/locations/use-cases/use-location-sites.ts create mode 100644 src/domain/work-orders/errors/board-patch-errors.ts create mode 100644 src/domain/work-orders/flag-colors.ts create mode 100644 src/domain/work-orders/mappers/work-order-board-detail-mapper.ts create mode 100644 src/domain/work-orders/mappers/work-order-board-mapper.ts create mode 100644 src/domain/work-orders/mappers/work-order-board-patch-mapper.ts delete mode 100644 src/domain/work-orders/mocks/dispatcher-filter-mock-data.ts delete mode 100644 src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts delete mode 100644 src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts delete mode 100644 src/domain/work-orders/mocks/wo-table-mock-data.ts delete mode 100644 src/domain/work-orders/mocks/wo-wizard-mock-data.ts create mode 100644 src/domain/work-orders/types/work-order-board-detail.ts create mode 100644 src/domain/work-orders/types/work-order-board.ts create mode 100644 src/domain/work-orders/utils/board-query-params.ts create mode 100644 src/domain/work-orders/utils/board-row-order-storage.ts create mode 100644 src/domain/work-orders/utils/comment-ownership.ts create mode 100644 src/domain/work-orders/utils/dispatcher-avatar-color.ts create mode 100644 src/domain/work-orders/utils/format-board-audit-action.ts create mode 100644 src/domain/work-orders/utils/service-notes-ui.ts create mode 100644 src/domain/work-orders/utils/should-notify-cross-week.ts create mode 100644 src/domain/work-orders/utils/slide-over-draft.ts create mode 100644 src/domain/work-orders/utils/vendor-dropdown-label.ts create mode 100644 src/domain/work-orders/utils/work-order-media-limits.ts create mode 100644 src/test/domain/locations/api/locations-sites-api.test.ts create mode 100644 src/test/domain/work-orders/mappers/vendor-dropdown-mapper.test.ts create mode 100644 src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts create mode 100644 src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts create mode 100644 src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts create mode 100644 src/test/domain/work-orders/utils/board-query-params.test.ts create mode 100644 src/test/domain/work-orders/utils/board-row-order-storage.test.ts create mode 100644 src/test/domain/work-orders/utils/comment-ownership.test.ts create mode 100644 src/test/domain/work-orders/utils/format-board-audit-action.test.ts create mode 100644 src/test/domain/work-orders/utils/service-notes-ui.test.ts create mode 100644 src/test/domain/work-orders/utils/should-notify-cross-week.test.ts create mode 100644 src/test/domain/work-orders/utils/slide-over-draft.test.ts create mode 100644 src/test/domain/work-orders/utils/wizard-date-utils.test.ts create mode 100644 src/test/domain/work-orders/utils/work-order-media-limits.test.ts diff --git a/src/api/api-error.ts b/src/api/api-error.ts index 6746359a..d9dabc83 100644 --- a/src/api/api-error.ts +++ b/src/api/api-error.ts @@ -1,5 +1,15 @@ import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + export function mapHttpStatusToMessage(status: number, data?: unknown): string { if (status === 401) { return "You are not authorized to access this page."; diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 9b2f25eb..b43647d6 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -26,6 +26,19 @@ export const API_PATHS = { addDispatchComment: "WorkOrder/AddDispatchComment", dispatchToVendor: "WorkOrder/DispatchToVendor", changeStatus: "WorkOrder/ChangeStatus", + board: "workorders/board", + boardCreate: "workorders/board", + boardSearch: "workorders/board/search", + boardPatch: (id: string | number) => `workorders/${id}/board`, + boardDetail: (id: string | number) => `workorders/${id}/detail`, + comments: (id: string | number) => `workorders/${id}/comments`, + commentItem: (workOrderId: string | number, commentId: string | number) => + `workorders/${workOrderId}/comments/${commentId}`, + media: (id: string | number) => `workorders/${id}/media`, + mediaItem: (id: string | number, mediaId: string | number) => + `workorders/${id}/media/${mediaId}`, + completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, + lookupsDispatchers: "workorders/lookups/dispatchers", }, pmSchedule: { getList: "PMSchedule/GetList", @@ -77,6 +90,7 @@ export const API_PATHS = { delete: "Location/Delete", deleteLocation: "Location/DeleteLocation", dropdown: "Location/Dropdown", + sites: "locations/sites", }, account: { getList: "Account/GetAccountList", @@ -108,7 +122,7 @@ export const API_PATHS = { create: "Vendor/Create", update: "Vendor/Update", delete: "Vendor/Delete", - dropdown: "Vendor/Dropdown", + dropdown: "vendors/Dropdown", facets: "Vendor/facets", deactivationImpact: "deactivation-impact", }, diff --git a/src/api/handle-api-response.ts b/src/api/handle-api-response.ts index 24ce2fc9..c41fb9ea 100644 --- a/src/api/handle-api-response.ts +++ b/src/api/handle-api-response.ts @@ -41,8 +41,11 @@ export function handlePaginatedResponse( } if (isRecord(data)) { - const nested = data.data ?? data.Data ?? data.value ?? data; - const items = Array.isArray(nested) ? (nested as T[]) : []; + const nested = data.data ?? data.Data ?? data.value; + const itemsFromPaged = + (Array.isArray(data.items) ? (data.items as T[]) : undefined) ?? + (Array.isArray(data.Items) ? (data.Items as T[]) : undefined); + const items = itemsFromPaged ?? (Array.isArray(nested) ? (nested as T[]) : []); const totalCount = (typeof data.totalCount === "number" ? data.totalCount : undefined) ?? @@ -54,6 +57,7 @@ export function handlePaginatedResponse( items, totalCount, page: + (typeof data.page === "number" ? data.page : undefined) ?? (typeof data.pageNumber === "number" ? data.pageNumber : undefined) ?? (typeof data.PageNumber === "number" ? data.PageNumber : undefined) ?? page, diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx index a86e37f5..9db6ca98 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx @@ -1,12 +1,9 @@ import { useMemo } from "react"; -import { Popover } from "@mui/material"; +import { Popover, Skeleton } from "@mui/material"; import { ChevronDown, UserX, Users } from "lucide-react"; import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; -import { - MOCK_DISPATCHER_USERS, - getDispatcherAvatarColor, -} from "@/domain/work-orders/mocks/dispatcher-filter-mock-data"; +import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; import { useMenuAnchor } from "@/hooks/use-menu-anchor"; @@ -15,6 +12,7 @@ type DispatcherFilterProps = { selectedIds: Set; onChange: (ids: Set) => void; users: WorkOrderUser[]; + usersLoading?: boolean; currentUserId?: string | number; }; @@ -40,6 +38,7 @@ export function DispatcherFilter({ selectedIds, onChange, users, + usersLoading = false, currentUserId, }: DispatcherFilterProps) { const { anchorEl, open, setAnchor, close } = useMenuAnchor(); @@ -57,11 +56,7 @@ export function DispatcherFilter({ }, [allSelected, meOnly, unassignedOnly, selectedIds.size]); const dispatchers = useMemo(() => { - const byId = new Map(); - for (const user of [...MOCK_DISPATCHER_USERS, ...users]) { - byId.set(String(user.id), user); - } - return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); + return [...users].sort((a, b) => a.name.localeCompare(b.name)); }, [users]); const toggle = (id: string) => { @@ -74,6 +69,18 @@ export function DispatcherFilter({ onChange(next); }; + if (usersLoading && users.length === 0) { + return ( + + ); + } + return ( <> void; users: WorkOrderUser[]; + usersLoading?: boolean; currentUserId?: string | number; filteredCount: number; scopeTotalCount: number; @@ -25,6 +26,7 @@ export function WorkOrdersFilterBar({ typeFilter, onTypeChange, users, + usersLoading = false, currentUserId, filteredCount, scopeTotalCount, @@ -37,6 +39,7 @@ export function WorkOrdersFilterBar({ selectedIds={dispatcherIds} onChange={onDispatcherChange} users={users} + usersLoading={usersLoading} currentUserId={currentUserId} /> diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx index ae377351..417ffe22 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -119,7 +119,6 @@ export function DateCell({ { - onSave(""); onSaveWeek?.(mon); setAnchorEl(null); }} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx index 77ac64e3..d5db15c6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx @@ -1,6 +1,6 @@ import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; -import { getDispatcherAvatarColor } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data"; +import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; import { HighlightText } from "./highlight-text"; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx index cda95c32..05c9460a 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx @@ -1,20 +1,10 @@ import { useState } from "react"; import { Flag, X } from "lucide-react"; import { Popover } from "@mui/material"; - -const FLAG_COLORS: { name: string; value: string }[] = [ - { name: "Red", value: "#EF4444" }, - { name: "Orange", value: "#F97316" }, - { name: "Amber", value: "#F59E0B" }, - { name: "Yellow", value: "#EAB308" }, - { name: "Green", value: "#22C55E" }, - { name: "Teal", value: "#14B8A6" }, - { name: "Blue", value: "#3B82F6" }, - { name: "Purple", value: "#8B5CF6" }, -]; +import { FLAG_COLORS } from "@/domain/work-orders/flag-colors"; type FlagCellProps = { - color?: string; + color?: string | null; disabled?: boolean; onChange: (color: string | null) => void; }; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index 4ae184df..24a3a8fa 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -1,41 +1,129 @@ import { useState } from "react"; - -import { Check } from "lucide-react"; - +import { Check, Plus } from "lucide-react"; import { Popover } from "@mui/material"; - -import { PmTypeIcon } from "../pm-type-icons"; - -import { HighlightText } from "./highlight-text"; - -import { HoverChevron } from "./hover-chevron"; +import { + ServiceNotesDialog, + type ServiceNotesDetails, +} from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog"; +import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text"; +import { HoverChevron } from "@/app/(protected)/workorders/_components/list/table/cells/hover-chevron"; +import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; +import { + hasServiceNotesDetails, + truncateServiceNotesPreview, +} from "@/domain/work-orders/utils/service-notes-ui"; type PMTypeCellProps = { value: string; - pmTypes: string[]; - q?: string; - onSave: (v: string) => void; + serviceNotes?: string; + extraServices?: string[]; + woNumber?: string; + onSaveDetails?: (details: ServiceNotesDetails) => void; }; -export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) { +export function PMTypeCell({ + value, + pmTypes, + q, + onSave, + serviceNotes = "", + extraServices = [], + woNumber, + onSaveDetails, +}: PMTypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); - const [search, setSearch] = useState(""); + const [modalOpen, setModalOpen] = useState(false); const filtered = search ? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase())) : pmTypes; + const extras = extraServices ?? []; + const hasDetails = hasServiceNotesDetails(serviceNotes, extras); + const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : ""; + + const modal = + onSaveDetails != null ? ( + + ) : null; + + if (hasDetails && onSaveDetails) { + return ( + <> + + {modal} + + ); + } + return ( <> @@ -70,7 +155,6 @@ export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) { anchorEl={anchorEl} onClose={() => { setAnchorEl(null); - setSearch(""); }} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} @@ -88,33 +172,50 @@ export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
+ {onSaveDetails ? ( + + ) : null} {filtered.map((pm) => ( ))}
+ {modal} ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx new file mode 100644 index 00000000..47088f4a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { Wrench } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields"; +import { Button } from "@/components/ui/button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; + +export type ServiceNotesDetails = { + pm: string; + extraServices: string[]; + serviceNotes: string; +}; + +type ServiceNotesDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + pm: string; + extraServices: string[]; + serviceNotes: string; + woNumber?: string; + pmTypes: string[]; + onSave: (details: ServiceNotesDetails) => void; +}; + +export function ServiceNotesDialog({ + open, + onOpenChange, + pm, + extraServices, + serviceNotes, + woNumber, + pmTypes, + onSave, +}: ServiceNotesDialogProps) { + const [sel, setSel] = useState([]); + const [note, setNote] = useState(""); + const [search, setSearch] = useState(""); + + useEffect(() => { + if (!open) { + return; + } + setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i)); + setNote(serviceNotes ?? ""); + setSearch(""); + }, [open, pm, extraServices, serviceNotes]); + + const toggle = (value: string) => { + setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])); + }; + + const services = pmTypes.map((value) => ({ id: value, value })); + + return ( + onOpenChange(false)} + maxWidth="sm" + fullWidth + slotProps={dialogBlurBackdropSlotProps} + > + + + Service & Notes + {woNumber ? ( + + #{woNumber} + + ) : null} + + e.stopPropagation()} + style={{ paddingTop: 8 }} + > + +
+ + +
+
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx index 6cb860b8..939b4ce4 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { MapPin } from "lucide-react"; +import { formatSitePlace } from "@/domain/locations/mappers/location-mapper"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; import { HighlightText } from "./highlight-text"; import { HoverChevron } from "./hover-chevron"; @@ -9,6 +10,8 @@ type SiteCellProps = { value: string; location: string; locationId: string | number; + city?: string; + state?: string; pocName?: string; pocPhone?: string; pocNotes?: string; @@ -21,6 +24,8 @@ export function SiteCell({ value, location, locationId, + city, + state, pocName, pocPhone, pocNotes, @@ -29,6 +34,7 @@ export function SiteCell({ onSave, }: SiteCellProps) { const [open, setOpen] = useState(false); + const place = formatSitePlace(city, state, location); return ( <> @@ -49,11 +55,16 @@ export function SiteCell({ > - {Boolean(location) && ( + {Boolean(place) && ( <> - · + + . + - + )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index 1dc0d678..04531258 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -11,8 +11,9 @@ import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import type { Location } from "@/domain/locations/types/location"; +import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; -import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data"; +import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; export type SitePatch = { @@ -47,27 +48,45 @@ function FieldLabel({ children }: { children: ReactNode }) { ); } -function extractSiteCode(name: string): string { - const dash = name.indexOf(" — "); - if (dash > 0) { - return name.slice(0, dash).trim(); +function extractLocationLabel(site: LocationOption): string { + const place = [site.city, site.state].filter(Boolean).join(", "); + if (place) { + return place; } - return name.slice(0, 8).toUpperCase(); -} - -function extractLocationLabel(name: string): string { - const dash = name.indexOf(" — "); - if (dash > 0) { - return name.slice(dash + 3).trim(); + const name = site.name; + for (const sep of [" · ", " — ", " - "]) { + const idx = name.indexOf(sep); + if (idx > 0) { + return name.slice(idx + sep.length).trim(); + } } return name; } +/** Board rows often have siteCode but no locationId — resolve via sites list. */ +function resolveLocationId( + locationId: string | number, + siteCode: string, + sites: LocationOption[], +): string { + const idStr = locationId !== "" && locationId != null ? String(locationId) : ""; + if (idStr && sites.some((s) => String(s.id) === idStr)) { + return idStr; + } + if (idStr && sites.length === 0) { + return idStr; + } + const code = siteCode.trim().toLowerCase(); + if (!code) { + return idStr; + } + const byCode = sites.find((s) => resolveSiteCode(s).toLowerCase() === code); + return byCode ? String(byCode.id) : idStr; +} + function buildMapsUrl(location: Location): string { - const parts = [location.address, location.city, location.state, location.zipCode] - .filter(Boolean) - .join(", "); - return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`; + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; } export function SiteDialog({ @@ -82,28 +101,34 @@ export function SiteDialog({ onSave, }: SiteDialogProps) { const [code, setCode] = useState(value); - const [locId, setLocId] = useState(String(locationId || "")); + 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 isMockLocation = locId.startsWith("mock-"); - const { data: apiLocationDetail } = useLocationDetail( - isMockLocation || !locId ? undefined : locId, - ); - const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail; + const { + data: locationDetail, + isLoading: locationDetailLoading, + isError: locationDetailError, + } = useLocationDetail(open && locId ? locId : undefined); + + const selected = sites.find((s) => String(s.id) === locId); + const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : ""; useEffect(() => { - if (open) { - setCode(value); - setLocId(String(locationId || "")); - setPn(pocName); - setPp(pocPhone); - setNotes(pocNotes); - setPocFilledFor(String(locationId || "")); + if (!open) { + return; } - }, [open, value, locationId, pocName, pocPhone, pocNotes]); + 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) { @@ -114,13 +139,11 @@ export function SiteDialog({ setPocFilledFor(locId); }, [open, locId, locationDetail, pocFilledFor]); - const selected = sites.find((s) => String(s.id) === locId); - const handlePick = (id: string) => { const next = sites.find((s) => String(s.id) === id); setLocId(id); if (next) { - setCode(extractSiteCode(next.name)); + setCode(resolveSiteCode(next)); } if (id !== pocFilledFor) { setPocFilledFor(""); @@ -171,7 +194,7 @@ export function SiteDialog({ /> - {locationDetail != null && Boolean(locId) && ( + {Boolean(locId) && (
- - {locationDetail.phone != null && } - {locationDetail.contactEmail != null && ( - - )} - {Boolean(locationDetail.address) && ( - - Open in Google Maps - + {locationDetail ? ( + <> + + {Boolean(locationDetail.phone) && ( + + )} + {Boolean(locationDetail.contactEmail) && ( + + )} + {Boolean(addressPreview) && ( + + Open in Google Maps + + )} + + ) : ( + <> + + {locationDetailLoading && ( +
+ + +
+ )} + {locationDetailError && ( + + Could not load full site details. City and site code above are still + available. + + )} + )}
)}
Point of Contact - - Auto-filled from records — edit if outdated + + Shown from site/work order records. Board edit saves site code only.
setPn(e.target.value)} + readOnly placeholder="POC name" className={DLG_INPUT_CLS} - style={DLG_INPUT_STYLE} + style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }} /> setPp(e.target.value)} + readOnly placeholder="POC phone" className={DLG_INPUT_CLS} - style={DLG_INPUT_STYLE} + style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }} />