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/handle-api-response.ts b/src/api/handle-api-response.ts index c41fb9ea..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,30 +43,13 @@ export function handlePaginatedResponse( } if (isRecord(data)) { - 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) ?? - (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.page === "number" ? data.page : undefined) ?? - (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/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/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/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index 103f8ed6..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,6 +1,4 @@ import { useState } from "react"; -import { Check, Plus } from "lucide-react"; -import { Popover } from "@mui/material"; import { ServiceNotesDialog, type ServiceNotesDetails, @@ -8,6 +6,8 @@ import { 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, @@ -46,6 +46,11 @@ export function PMTypeCell({ const hasDetails = hasServiceNotesDetails(serviceNotes, extras); const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : ""; + const closePopover = () => { + setAnchorEl(null); + setSearch(""); + }; + const modal = onSaveDetails != null ? ( - + setModalOpen(true)} + /> {modal} ); @@ -150,71 +112,23 @@ export function PMTypeCell({ - { - 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" - /> -
- -
- {Boolean(onSaveDetails) && ( - - )} - {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/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. + +
+ + +