diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json index 93a682d9..401e1e70 100644 --- a/scripts/governance-baseline.json +++ b/scripts/governance-baseline.json @@ -12,11 +12,6 @@ "path": "src/app/(protected)/vendors/index.tsx", "maxLines": 612, "reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction." - }, - { - "path": "src/domain/work-orders/mappers/work-order-mapper.ts", - "maxLines": 572, - "reason": "API<->domain mapper; long but cohesive mapping logic, pending split by responsibility." } ] } diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 41bb1de9..9e36d764 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -164,11 +164,13 @@ function maintainabilityGate(files) { if (files.length === 0) { return { skipped: true, reason: "no changed governed TS/TSX files" }; } - const eslintBin = path.join(ROOT, "node_modules", ".bin", "eslint"); + // Invoke eslint via node so Windows (no shebang exec) and Unix both work. + const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js"); const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]); const result = spawnSync( - eslintBin, + process.execPath, [ + eslintJs, ...files, ...ruleArgs, "--max-warnings=0", 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..5adefa96 100644 --- a/src/api/handle-api-response.ts +++ b/src/api/handle-api-response.ts @@ -1,3 +1,5 @@ +import { readItems, readPage, readPageSize, readTotalCount } from "@/api/paginated-response-fields"; + export interface PaginatedResult { items: T[]; totalCount: number; @@ -41,26 +43,13 @@ export function handlePaginatedResponse( } if (isRecord(data)) { - const nested = data.data ?? data.Data ?? data.value ?? data; - const items = Array.isArray(nested) ? (nested as T[]) : []; - - const totalCount = - (typeof data.totalCount === "number" ? data.totalCount : undefined) ?? - (typeof data.TotalCount === "number" ? data.TotalCount : undefined) ?? - (typeof data.Count === "number" ? data.Count : undefined) ?? - items.length; + const items = readItems(data); return { items, - totalCount, - page: - (typeof data.pageNumber === "number" ? data.pageNumber : undefined) ?? - (typeof data.PageNumber === "number" ? data.PageNumber : undefined) ?? - page, - pageSize: - (typeof data.pageSize === "number" ? data.pageSize : undefined) ?? - (typeof data.PageSize === "number" ? data.PageSize : undefined) ?? - pageSize, + totalCount: readTotalCount(data, items.length), + page: readPage(data, page), + pageSize: readPageSize(data, pageSize), }; } diff --git a/src/api/paginated-response-fields.ts b/src/api/paginated-response-fields.ts new file mode 100644 index 00000000..157ffbc4 --- /dev/null +++ b/src/api/paginated-response-fields.ts @@ -0,0 +1,36 @@ +/** Field readers for paginated API payloads that tolerate PascalCase/camelCase and nested envelopes. */ + +export function readItems(record: Record): T[] { + const nested = record.data ?? record.Data ?? record.value; + const itemsFromPaged = + (Array.isArray(record.items) ? (record.items as T[]) : undefined) ?? + (Array.isArray(record.Items) ? (record.Items as T[]) : undefined); + + return itemsFromPaged ?? (Array.isArray(nested) ? (nested as T[]) : []); +} + +export function readTotalCount(record: Record, fallback: number): number { + return ( + (typeof record.totalCount === "number" ? record.totalCount : undefined) ?? + (typeof record.TotalCount === "number" ? record.TotalCount : undefined) ?? + (typeof record.Count === "number" ? record.Count : undefined) ?? + fallback + ); +} + +export function readPage(record: Record, fallback: number): number { + return ( + (typeof record.page === "number" ? record.page : undefined) ?? + (typeof record.pageNumber === "number" ? record.pageNumber : undefined) ?? + (typeof record.PageNumber === "number" ? record.PageNumber : undefined) ?? + fallback + ); +} + +export function readPageSize(record: Record, fallback: number): number { + return ( + (typeof record.pageSize === "number" ? record.pageSize : undefined) ?? + (typeof record.PageSize === "number" ? record.PageSize : undefined) ?? + fallback + ); +} 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/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts new file mode 100644 index 00000000..53c2f381 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -0,0 +1,41 @@ +import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; + +export function buildInlineDraftCreateInput( + draft: WorkOrderTableDraft, + users: WorkOrderUser[], + sites: LocationOption[], +): WorkOrderWizardDraft { + const assignee = users.find((u) => String(u.id) === draft.dispatcherId); + const location = sites.find((s) => String(s.id) === String(draft.locationId)); + const locationName = draft.location || draft.site || location?.name || ""; + + return { + locationId: String(draft.locationId || ""), + locationName, + siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }), + woNumber: draft.woNumber, + type: draft.type, + assigneeId: draft.dispatcherId, + assigneeName: assignee?.name ?? "", + scheduledOn: draft.scheduledOn, + dueDate: draft.dueDate, + pm: draft.pm, + extraServices: draft.extraServices ?? [], + pmNote: draft.serviceNotes ?? "", + vendorId: draft.vendorId || "", + vendorName: draft.company, + techPhone: draft.techPhone, + vendorNotes: "", + apptStart: draft.apptStart, + apptEnd: draft.apptEnd, + status: draft.status || "Incomplete", + pocName: draft.pocName, + pocPhone: draft.pocPhone, + pocNotes: draft.pocNotes, + scheduleWeekOnly: draft.scheduleWeekOnly, + targetWeek: draft.targetWeek || "", + }; +} 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/dialog-field-label.tsx b/src/app/(protected)/workorders/_components/list/table/cells/dialog-field-label.tsx new file mode 100644 index 00000000..55ebe729 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/dialog-field-label.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +export function FieldLabel({ children }: { children: ReactNode }) { + return ( + + ); +} 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..58dbf80f 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,91 @@ import { useState } from "react"; - -import { Check } 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 { PmTypeDetailsTrigger } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger"; +import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover"; +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 closePopover = () => { + setAnchorEl(null); + setSearch(""); + }; + + const modal = + onSaveDetails != null ? ( + + ) : null; + + if (hasDetails && onSaveDetails) { + return ( + <> + setModalOpen(true)} + /> + {modal} + + ); + } + return ( <> - { - setAnchorEl(null); - - setSearch(""); + search={search} + filtered={filtered} + value={value} + showAddDetails={Boolean(onSaveDetails)} + onSearchChange={setSearch} + onClose={closePopover} + onPick={(pm) => { + onSave(pm); + closePopover(); }} - anchorOrigin={{ vertical: "bottom", horizontal: "left" }} - onClick={(e) => e.stopPropagation()} - slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }} - > -
- setSearch(e.target.value)} - placeholder="Search PM type…" - className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" - /> -
- -
- {filtered.map((pm) => ( - - ))} -
-
+ onAddDetails={() => { + closePopover(); + setModalOpen(true); + }} + /> + {modal} ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx new file mode 100644 index 00000000..4595a6b9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx @@ -0,0 +1,71 @@ +import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text"; +import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; + +type PmTypeDetailsTriggerProps = { + value: string; + q?: string; + notePreview: string; + extrasCount: number; + onOpenDetails: () => void; +}; + +export function PmTypeDetailsTrigger({ + value, + q, + notePreview, + extrasCount, + onOpenDetails, +}: PmTypeDetailsTriggerProps) { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx new file mode 100644 index 00000000..1df4a262 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx @@ -0,0 +1,84 @@ +import { Check, Plus } from "lucide-react"; +import { Popover } from "@mui/material"; +import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; + +type PmTypePickerPopoverProps = { + anchorEl: HTMLElement | null; + search: string; + filtered: string[]; + value: string; + showAddDetails: boolean; + onSearchChange: (value: string) => void; + onClose: () => void; + onPick: (pm: string) => void; + onAddDetails: () => void; +}; + +export function PmTypePickerPopover({ + anchorEl, + search, + filtered, + value, + showAddDetails, + onSearchChange, + onClose, + onPick, + onAddDetails, +}: PmTypePickerPopoverProps) { + return ( + e.stopPropagation()} + slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }} + > +
+ onSearchChange(e.target.value)} + placeholder="Search PM type…" + className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" + /> +
+ +
+ {showAddDetails && ( + + )} + {filtered.map((pm) => ( + + ))} +
+
+ ); +} 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..c0166809 --- /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 + {Boolean(woNumber) && ( + + #{woNumber} + + )} + + 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-detail-panel.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx new file mode 100644 index 00000000..07b1bb94 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx @@ -0,0 +1,79 @@ +import { ExternalLink, Mail, MapPin, Phone } from "lucide-react"; +import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; +import { Text } from "@/components/ui/text"; +import type { Location } from "@/domain/locations/types/location"; +import { buildMapsUrl } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; + +type SiteDialogDetailPanelProps = { + locationDetail: Location | undefined; + locationDetailLoading: boolean; + locationDetailError: boolean; + addressPreview: string; + fallbackLabel: string; +}; + +function SiteDialogSkeleton() { + return ( +
+ + +
+ ); +} + +export function SiteDialogDetailPanel({ + locationDetail, + locationDetailLoading, + locationDetailError, + addressPreview, + fallbackLabel, +}: SiteDialogDetailPanelProps) { + return ( +
+ {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. + + )} + + )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts new file mode 100644 index 00000000..e28e1f10 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts @@ -0,0 +1,45 @@ +import type { Location } from "@/domain/locations/types/location"; +import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; + +export function extractLocationLabel(site: LocationOption): string { + const place = [site.city, site.state].filter(Boolean).join(", "); + if (place) { + return place; + } + 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. */ +export 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; +} + +export function buildMapsUrl(location: Location): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx new file mode 100644 index 00000000..145250c2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx @@ -0,0 +1,47 @@ +import { + DLG_INPUT_CLS, + DLG_INPUT_STYLE, +} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; +import { Text } from "@/components/ui/text"; +import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; + +type SiteDialogPocFieldsProps = { + pocName: string; + pocPhone: string; + pocNotes: string; +}; + +export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) { + return ( +
+ Point of Contact + + Shown from site/work order records. Board edit saves site code only. + +
+ + +