diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts index 97d23975..13c7b1eb 100644 --- a/e2e/vendors/vendor-operations.spec.ts +++ b/e2e/vendors/vendor-operations.spec.ts @@ -16,9 +16,68 @@ async function fulfillJson(route: Route, body: unknown, status = 200) { async function authenticate(page: Page) { await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + await page.route("**/api/Dashboard/Stats", (route) => + fulfillJson(route, { + data: { + openWorkOrders: 0, + pendingApprovals: 0, + scheduledPMs: 0, + activeAssets: 0, + }, + }), + ); + await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] })); } test.describe("Vendor operations", () => { + test("keeps the most specific vendor navigation item exclusively current", async ({ page }) => { + await authenticate(page); + await page.route("**/api/vendor-operations/insights*", (route) => + fulfillJson(route, { data: [] }), + ); + + await page.goto("/vendors/insights"); + + const vendorLinks = page.locator('a[href^="/vendors"]'); + await expect( + vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }), + ).toHaveAttribute("aria-current", "page"); + await expect( + vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }), + ).not.toHaveAttribute("aria-current", "page"); + await expect( + vendorLinks.filter({ has: page.getByText("Site Preferences", { exact: true }) }), + ).not.toHaveAttribute("aria-current", "page"); + }); + + test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await authenticate(page); + await page.route("**/api/dispatches*", (route) => fulfillJson(route, { items: [], total: 0 })); + await page.route("**/api/Vendor/GetVendorList*", (route) => + fulfillJson(route, { Data: [], TotalCount: 0 }), + ); + + await page.goto("/vendor-pos"); + + await expect(page.getByRole("heading", { name: "Vendor POs" })).toBeVisible(); + await expect(page.getByText("0 total", { exact: true })).toBeVisible(); + await expect(page.getByPlaceholder("Search PO #, dispatch, vendor, WO...")).toBeVisible(); + await expect(page.locator("label").filter({ hasText: /^Status$/ })).toBeVisible(); + await expect(page.locator("label").filter({ hasText: /^Vendor$/ })).toBeVisible(); + await expect(page.getByLabel("From", { exact: true })).toBeVisible(); + await expect(page.getByLabel("To", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible(); + + await expect + .poll(() => + page + .getByPlaceholder("Search PO #, dispatch, vendor, WO...") + .evaluate((element) => element.clientWidth), + ) + .toBeGreaterThanOrEqual(280); + }); + test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({ page, }) => { diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 29df8921..4dae7254 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -80,9 +80,19 @@ async function captureVisual(page: Page, path: string) { async function mockVendorApi( page: Page, - options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {}, + options: { + listError?: boolean; + deactivationBlocked?: boolean; + deleteConflict?: boolean; + legacyGateway?: boolean; + } = {}, ) { const state: MockState = { listUrls: [] }; + const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor })); + if (options.legacyGateway) { + vendorRecords[0].Phone = "555-1234 ext 9"; + vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy"; + } await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); @@ -131,7 +141,7 @@ async function mockVendorApi( } const isActive = url.searchParams.get("isActive") !== "false"; - const source = isActive ? activeVendors : inactiveVendors; + const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive); const search = (url.searchParams.get("search") ?? "").toLowerCase(); const company = url.searchParams.get("companies[0]"); const filtered = source.filter( @@ -175,8 +185,12 @@ async function mockVendorApi( const id = route.request().url().split("/").pop() ?? ""; if (route.request().method() === "PUT") { state.updatedBody = route.request().postDataJSON(); + const vendor = vendorRecords.find((item) => String(item.Id) === id); + if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") { + vendor.IsActive = state.updatedBody.isActive; + } await fulfillJson(route, { - data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) }, + data: { ...vendor, ...state.updatedBody, Id: Number(id) }, }); return; } @@ -190,10 +204,12 @@ async function mockVendorApi( return; } state.deletedId = id; + const vendor = vendorRecords.find((item) => String(item.Id) === id); + if (options.legacyGateway && vendor) vendor.IsActive = false; await fulfillJson(route, { status: "200" }); return; } - const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id); + const vendor = vendorRecords.find((item) => String(item.Id) === id); await fulfillJson(route, { data: vendor }); }); await page.route(/\/api\/vendors\/?$/, async (route) => { @@ -336,6 +352,30 @@ test.describe("Vendor directory prototype parity", () => { expect(state.updatedBody?.isActive).toBe(false); }); + test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => { + const state = await mockVendorApi(page, { legacyGateway: true }); + await page.goto("/vendors"); + + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Deactivate", exact: true }).click(); + await expect.poll(() => state.deletedId).toBe("1"); + + await page.getByRole("tab", { name: "Inactive (2)" }).click(); + await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + const detailDrawer = page.locator(".MuiDrawer-paper").last(); + await detailDrawer.getByRole("button", { name: "Edit" }).click(); + await expect(detailDrawer.getByRole("button", { name: "Save" })).toBeDisabled(); + + await detailDrawer.getByRole("switch", { name: "Inactive" }).click(); + await detailDrawer.getByRole("button", { name: "Save" }).click(); + await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); + await expect(detailDrawer.getByText("Active").first()).toBeVisible(); + + await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); + await page.getByRole("tab", { name: "Active (2)" }).click(); + await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); + }); + test("supports keyboard dismissal and free-text company creation", async ({ page }) => { const state = await mockVendorApi(page); await page.goto("/vendors"); 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 9e388ef4..9e36d764 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -164,6 +164,7 @@ function maintainabilityGate(files) { if (files.length === 0) { return { skipped: true, reason: "no changed governed TS/TSX files" }; } + // 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( diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index a22ef333..b43647d6 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -26,8 +26,11 @@ export const API_PATHS = { addDispatchComment: "WorkOrder/AddDispatchComment", dispatchToVendor: "WorkOrder/DispatchToVendor", changeStatus: "WorkOrder/ChangeStatus", - boardDetail: (id: string | number) => `workorders/${id}/detail`, + 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}`, @@ -35,6 +38,7 @@ export const API_PATHS = { 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", @@ -86,6 +90,7 @@ export const API_PATHS = { delete: "Location/Delete", deleteLocation: "Location/DeleteLocation", dropdown: "Location/Dropdown", + sites: "locations/sites", }, account: { getList: "Account/GetAccountList", @@ -117,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)/vendor-pos/_components/vendor-pos-list-controls.tsx b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-controls.tsx new file mode 100644 index 00000000..df8ab433 --- /dev/null +++ b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-controls.tsx @@ -0,0 +1,157 @@ +import { + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import type { VendorPosFilters } from "../_hooks/use-vendor-pos-filters"; + +const STATUS_OPTIONS = [ + "", + "Sent", + "Acknowledged", + "In Progress", + "Completed", + "Verified", + "Cancelled", +]; + +interface VendorPosListControlsProps { + filters: VendorPosFilters; + vendors: VendorListItem[]; + totalCount: number; + isFetching: boolean; + isLoading: boolean; + onSearchChange: (value: string) => void; + onFilterChange: (name: Exclude, value: string) => void; + onClearFilters: () => void; +} + +export function VendorPosListControls({ + filters, + vendors, + totalCount, + isFetching, + isLoading, + onSearchChange, + onFilterChange, + onClearFilters, +}: VendorPosListControlsProps) { + return ( + <> + + + + Vendor POs + + + {totalCount} total + + {isFetching && !isLoading && } + + + onSearchChange(event.target.value)} + placeholder="Search PO #, dispatch, vendor, WO..." + sx={{ width: { xs: "100%", sm: 360 }, minWidth: 280, flexShrink: 0 }} + /> + + + + + Status + + + + + Vendor + + + + onFilterChange("dateFrom", event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }} + /> + onFilterChange("dateTo", event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }} + /> + + + + ); +} diff --git a/src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx new file mode 100644 index 00000000..28d8f7c6 --- /dev/null +++ b/src/app/(protected)/vendor-pos/_components/vendor-pos-list-table.tsx @@ -0,0 +1,111 @@ +import { + Button, + Chip, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import type { VendorPoListItem } from "@/domain/vendor-pos/types/vendor-po"; +import { toDateInputValue } from "@/lib/time-utils"; + +interface VendorPosListTableProps { + rows: VendorPoListItem[]; + isLoading: boolean; + onNavigate: (id: string | number) => void; +} + +function formatMoney(value: number | null): string { + if (value == null) return "—"; + return `$${value.toFixed(2)}`; +} + +function formatDate(value: string): string { + if (!value) return "—"; + return toDateInputValue(value) || "—"; +} + +export function VendorPosListTable({ rows, isLoading, onNavigate }: VendorPosListTableProps) { + return ( + + + + + PO # + Dispatch # + Vendor + WO # + Location + NTE + Status + Uplifts + Dispatched + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No vendor POs found + + + ) : ( + rows.map((row) => ( + onNavigate(row.id)} + > + + + + {row.dispatchNumber || "—"} + {row.vendorName || "—"} + {row.primaryWOInternalNumber || "—"} + {row.locationName || "—"} + {formatMoney(row.nteAmount)} + + + + + {row.pendingUpliftCount > 0 ? ( + + ) : ( + "—" + )} + + {formatDate(row.dispatchedAt)} + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts b/src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts new file mode 100644 index 00000000..d88fa2fa --- /dev/null +++ b/src/app/(protected)/vendor-pos/_hooks/use-vendor-pos-filters.ts @@ -0,0 +1,87 @@ +import { useMemo, useState } from "react"; +import type { VendorPoListParams } from "@/domain/vendor-pos/types/vendor-po"; +import { useDebounce } from "@/hooks/use-debounce"; + +export interface VendorPosFilters { + search: string; + status: string; + vendorId: string; + dateFrom: string; + dateTo: string; +} + +type VendorPosFilterName = Exclude; + +const INITIAL_FILTERS: VendorPosFilters = { + search: "", + status: "", + vendorId: "", + dateFrom: "", + dateTo: "", +}; + +export function useVendorPosFilters() { + const [filters, setFilters] = useState(INITIAL_FILTERS); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const debouncedSearch = useDebounce(filters.search, 300); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + status: filters.status, + vendorId: filters.vendorId, + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, + }), + [ + debouncedSearch, + filters.dateFrom, + filters.dateTo, + filters.status, + filters.vendorId, + page, + pageSize, + ], + ); + + const setSearch = (search: string) => { + setFilters((current) => ({ ...current, search })); + setPage(0); + }; + + const setFilter = (name: VendorPosFilterName, value: string) => { + setFilters((current) => ({ ...current, [name]: value })); + setPage(0); + }; + + const clearFilters = () => { + setFilters((current) => ({ + ...current, + status: "", + vendorId: "", + dateFrom: "", + dateTo: "", + })); + setPage(0); + }; + + const changePageSize = (nextPageSize: number) => { + setPageSize(nextPageSize); + setPage(0); + }; + + return { + filters, + page, + pageSize, + listParams, + setSearch, + setFilter, + clearFilters, + setPage, + changePageSize, + }; +} diff --git a/src/app/(protected)/vendor-pos/index.tsx b/src/app/(protected)/vendor-pos/index.tsx index 81d07243..17c65bce 100644 --- a/src/app/(protected)/vendor-pos/index.tsx +++ b/src/app/(protected)/vendor-pos/index.tsx @@ -1,300 +1,59 @@ -import { useMemo, useState } from "react"; +import { Alert, Box, TablePagination } from "@mui/material"; import { useNavigate } from "react-router"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - FormControl, - InputLabel, - MenuItem, - Paper, - Select, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TablePagination, - TableRow, - TextField, - Typography, -} from "@mui/material"; +import { VendorPosListControls } from "./_components/vendor-pos-list-controls"; +import { VendorPosListTable } from "./_components/vendor-pos-list-table"; +import { useVendorPosFilters } from "./_hooks/use-vendor-pos-filters"; import { useVendorPosList } from "@/domain/vendor-pos/use-cases/use-vendor-pos-list"; import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; -import { useDebounce } from "@/hooks/use-debounce"; -import { toDateInputValue } from "@/lib/time-utils"; - -const STATUS_OPTIONS = [ - "", - "Sent", - "Acknowledged", - "In Progress", - "Completed", - "Verified", - "Cancelled", -]; - -function formatMoney(value: number | null): string { - if (value == null) return "—"; - return `$${value.toFixed(2)}`; -} - -function formatDate(value: string): string { - if (!value) return "—"; - return toDateInputValue(value) || "—"; -} export default function VendorPosListPage() { const navigate = useNavigate(); - const [search, setSearch] = useState(""); - const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(12); - const [status, setStatus] = useState(""); - const [vendorId, setVendorId] = useState(""); - const [dateFrom, setDateFrom] = useState(""); - const [dateTo, setDateTo] = useState(""); - - const debouncedSearch = useDebounce(search, 300); - - const listParams = useMemo( - () => ({ - search: debouncedSearch, - page: page + 1, - pageSize, - status, - vendorId, - dateFrom, - dateTo, - }), - [debouncedSearch, page, pageSize, status, vendorId, dateFrom, dateTo], - ); - + const { + filters, + page, + pageSize, + listParams, + setSearch, + setFilter, + clearFilters, + setPage, + changePageSize, + } = useVendorPosFilters(); const { data, isLoading, isFetching, error } = useVendorPosList(listParams); const { data: vendorsData } = useVendorsList({ page: 1, pageSize: 500 }); - const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; const vendors = vendorsData?.items ?? []; - const clearFilters = () => { - setStatus(""); - setVendorId(""); - setDateFrom(""); - setDateTo(""); - setPage(0); - }; - return ( - - - - Vendor POs - - - {totalCount} total - - {isFetching && !isLoading && } - - - { - setSearch(e.target.value); - setPage(0); - }} - placeholder="Search PO #, dispatch, vendor, WO..." - className="min-w-[280px]" - /> - - - - Status - - - - - Vendor - - - - { - setDateFrom(e.target.value); - setPage(0); - }} - slotProps={{ inputLabel: { shrink: true } }} - /> - { - setDateTo(e.target.value); - setPage(0); - }} - slotProps={{ inputLabel: { shrink: true } }} - /> - - + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load vendor POs"} )} - - - - - PO # - Dispatch # - Vendor - WO # - Location - NTE - Status - Uplifts - Dispatched - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - - - No vendor POs found - - - - ) : ( - rows.map((row) => ( - navigate(`/vendor-pos/${row.id}`)} - > - - - - {row.dispatchNumber || "—"} - {row.vendorName || "—"} - {row.primaryWOInternalNumber || "—"} - {row.locationName || "—"} - {formatMoney(row.nteAmount)} - - - - - {row.pendingUpliftCount > 0 ? ( - - ) : ( - "—" - )} - - {formatDate(row.dispatchedAt)} - - )) - )} - -
-
+ navigate(`/vendor-pos/${id}`)} + /> setPage(nextPage)} + onPageChange={(_event, nextPage) => setPage(nextPage)} rowsPerPage={pageSize} - onRowsPerPageChange={(e) => { - setPageSize(Number(e.target.value)); - setPage(0); - }} + onRowsPerPageChange={(event) => changePageSize(Number(event.target.value))} rowsPerPageOptions={[12, 24, 48]} />
diff --git a/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts b/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts new file mode 100644 index 00000000..56a8fe74 --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useForm, useWatch, type FieldErrors, type Resolver } from "react-hook-form"; +import type { ZodError } from "zod"; +import { + getChangedVendorValues, + vendorUpdateSchema, + type VendorFormValues, +} from "@/domain/vendors/schemas/vendor-schema"; +import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor"; +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; +import type { Vendor, VendorFacetCompany } from "@/domain/vendors/types/vendor"; + +const DEFAULT_VALUES: VendorFormValues = { + companyName: "", + contactName: "", + email: "", + phone: "", + companyPhone: "", + googleMapsUrl: "", + notes: "", + totalJobs: 0, + address: "", + city: "", + state: "", + zip: "", + tradeSpecialties: "", + isActive: true, + companyId: null, + preferredContact: "Phone", +}; + +function toFormValues(vendor: Vendor): VendorFormValues { + return { + companyName: vendor.companyName ?? "", + contactName: vendor.contactName ?? "", + email: vendor.email ?? "", + phone: vendor.phone ?? "", + companyPhone: vendor.companyPhone ?? "", + googleMapsUrl: vendor.googleMapsUrl ?? "", + notes: vendor.notes ?? "", + totalJobs: vendor.totalJobs ?? 0, + address: vendor.address ?? "", + city: vendor.city ?? "", + state: vendor.state ?? "", + zip: vendor.zip ?? "", + tradeSpecialties: vendor.tradeSpecialties ?? "", + isActive: vendor.isActive, + companyId: vendor.companyId ?? null, + preferredContact: vendor.preferredContact ?? "Phone", + }; +} + +function buildFieldErrors(error: ZodError): FieldErrors { + return error.issues.reduce>((fieldErrors, issue) => { + const field = issue.path[0]; + if (typeof field === "string") { + fieldErrors[field as keyof VendorFormValues] = { + type: issue.code, + message: issue.message, + }; + } + return fieldErrors; + }, {}); +} + +interface UseVendorDrawerFormProps { + detail: Vendor | undefined; + open: boolean; + vendorId: string | number | undefined; + onModeChange: (mode: "view" | "edit") => void; +} + +export function useVendorDetailDrawerForm({ + detail, + open, + vendorId, + onModeChange, +}: UseVendorDrawerFormProps) { + const { data: facets } = useVendorFacets(); + const updateVendor = useUpdateVendor(); + const [submitError, setSubmitError] = useState(null); + const [original, setOriginal] = useState(null); + const originalRef = useRef(null); + const companies = useMemo(() => facets?.companies ?? [], [facets]); + + const resolver = useCallback>(async (values) => { + const result = vendorUpdateSchema.safeParse( + getChangedVendorValues(values, originalRef.current), + ); + return result.success + ? { values, errors: {} } + : { values: {}, errors: buildFieldErrors(result.error) }; + }, []); + + const form = useForm({ + resolver, + mode: "onChange", + defaultValues: DEFAULT_VALUES, + }); + const { control, reset, setValue, trigger } = form; + const currentValues = useWatch({ control }); + const changedValues = useMemo( + () => getChangedVendorValues(currentValues, original), + [currentValues, original], + ); + + useEffect(() => { + if (open && detail) { + const values = toFormValues(detail); + reset(values); + setOriginal(values); + originalRef.current = values; + setSubmitError(null); + } else if (!open) { + setOriginal(null); + originalRef.current = null; + setSubmitError(null); + } + }, [open, detail, reset]); + + const onCompanySelected = (company: VendorFacetCompany | null) => { + if (!company) return; + const options = { shouldDirty: true, shouldValidate: true } as const; + setValue("companyId", company.companyId, options); + setValue("companyPhone", company.companyPhone, options); + setValue("email", company.email, options); + setValue("address", company.address, options); + setValue("city", company.city, options); + setValue("state", company.state, options); + setValue("zip", company.zip, options); + setValue("googleMapsUrl", company.googleMapsUrl, options); + void trigger(); + }; + + const cancel = () => { + if (original) reset(original); + setSubmitError(null); + onModeChange("view"); + }; + + const submit = (values: VendorFormValues) => { + if (vendorId === undefined || vendorId === "") return; + const changed = getChangedVendorValues(values, original); + if (Object.keys(changed).length === 0) return; + setSubmitError(null); + updateVendor.mutate( + { id: vendorId, values: changed }, + { + onSuccess: () => onModeChange("view"), + onError: (error: Error) => setSubmitError(error.message || "Failed to save vendor"), + }, + ); + }; + + return { + ...form, + cancel, + companies, + hasChanges: Object.keys(changedValues).length > 0, + isFormValid: vendorUpdateSchema.safeParse(changedValues).success, + onCompanySelected, + submit, + submitError, + updateVendor, + }; +} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx new file mode 100644 index 00000000..e121c63d --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx @@ -0,0 +1,338 @@ +import type { FormEventHandler, ReactNode } from "react"; +import { Controller, type Control, type FieldErrors } from "react-hook-form"; +import CloseIcon from "@mui/icons-material/Close"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import LaunchIcon from "@mui/icons-material/Launch"; +import { + Alert, + Box, + Button, + Chip, + FormControlLabel, + IconButton, + Link, + Stack, + Switch, + Tooltip, + Typography, +} from "@mui/material"; +import { VendorFormFields } from "./vendor-form-fields"; +import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import type { + Vendor, + VendorFacetCompany, + VendorListItem, + VendorPreferredContact, +} from "@/domain/vendors/types/vendor"; + +const PREFERRED_CONTACT_LABELS: Record = { + Phone: "Phone", + Email: "Email", + Text: "Text Message", +}; + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "—"; + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; + return (first + last).toUpperCase(); +} + +interface HeaderProps { + vendor: VendorListItem | null; + mode: "view" | "edit"; + pending: boolean; + onClose: () => void; +} + +export function VendorDrawerHeader({ vendor, mode, pending, onClose }: HeaderProps) { + return ( + `1px solid ${theme.palette.divider}`, + }} + > + + + {getInitials(vendor?.contactName ?? "")} + + + + {vendor?.companyName || "Vendor"} + + + {mode === "edit" ? "Editing vendor" : "Vendor details"} + + + + + + + + ); +} + +interface ViewProps { + detail: Vendor; + mapsUrl: string | undefined; + submitError: string | null; + trades: string[]; +} + +export function VendorDrawerView({ detail, mapsUrl, submitError, trades }: ViewProps) { + return ( + + {Boolean(submitError) && {submitError}} + + + {Boolean(trades[0]) && ( + 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]} + /> + )} + + + + + + + + + + + + + + {Boolean(mapsUrl) && ( + + + Open in Google Maps + + )} + + + + + + {Boolean(detail.notes) && ( + + + {detail.notes} + + + )} + + ); +} + +function TradesSection({ trades }: { trades: string[] }) { + return ( + + {trades.length === 0 ? ( + + No trades specified. + + ) : ( + + {trades.map((trade, index) => ( + + ))} + + )} + + ); +} + +interface EditProps { + control: Control; + errors: FieldErrors; + companies: VendorFacetCompany[]; + tradeOptions: string[]; + submitError: string | null; + onCompanySelected: (company: VendorFacetCompany | null) => void; + onSubmit: FormEventHandler; +} + +export function VendorDrawerEdit({ + control, + errors, + companies, + tradeOptions, + submitError, + onCompanySelected, + onSubmit, +}: EditProps) { + return ( + + + {Boolean(submitError) && {submitError}} + ( + + } + label={field.value ? "Active" : "Inactive"} + sx={{ alignSelf: "flex-start" }} + /> + )} + /> + + + + ); +} + +interface ActionsProps { + mode: "view" | "edit"; + mapsUrl: string | undefined; + pending: boolean; + canSave: boolean; + onCancel: () => void; + onEdit: () => void; +} + +export function VendorDrawerActions({ + mode, + mapsUrl, + pending, + canSave, + onCancel, + onEdit, +}: ActionsProps) { + return ( + `1px solid ${theme.palette.divider}`, + }} + > + {mode === "view" ? ( + <> + {Boolean(mapsUrl) && ( + + + + )} + + + + ) : ( + <> + + + + )} + + ); +} + +function DetailSection({ title, children }: { title: string; children: ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + +function DetailField({ label, value }: { label: string; value: string }) { + const display = value && value.trim() !== "" ? value : "—"; + return ( + + + {label} + + {display} + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 9dcd5eed..4ade2647 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,47 +1,16 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { useForm, useWatch, Controller } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import CloseIcon from "@mui/icons-material/Close"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import LaunchIcon from "@mui/icons-material/Launch"; +import { Alert, Box, CircularProgress, Drawer, Stack, Typography } from "@mui/material"; import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Drawer, - FormControlLabel, - IconButton, - Link, - Stack, - Switch, - Tooltip, - Typography, -} from "@mui/material"; -import { VendorFormFields } from "./vendor-form-fields"; -import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor"; + VendorDrawerActions, + VendorDrawerEdit, + VendorDrawerHeader, + VendorDrawerView, +} from "./vendor-detail-drawer-content"; +import { useVendorDetailDrawerForm } from "./use-vendor-detail-drawer"; import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail"; -import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; -import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; const DRAWER_WIDTH = 420; -const PREFERRED_CONTACT_LABELS: Record = { - Phone: "Phone", - Email: "Email", - Text: "Text Message", -}; - -function getInitials(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "—"; - const first = parts[0]?.[0] ?? ""; - const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; - return (first + last).toUpperCase(); -} - function splitTrades(value: string | undefined): string[] { return (value ?? "") .split(",") @@ -49,27 +18,6 @@ function splitTrades(value: string | undefined): string[] { .filter(Boolean); } -function toFormValues(vendor: Vendor): VendorFormValues { - return { - companyName: vendor.companyName ?? "", - contactName: vendor.contactName ?? "", - email: vendor.email ?? "", - phone: vendor.phone ?? "", - companyPhone: vendor.companyPhone ?? "", - googleMapsUrl: vendor.googleMapsUrl ?? "", - notes: vendor.notes ?? "", - totalJobs: vendor.totalJobs ?? 0, - address: vendor.address ?? "", - city: vendor.city ?? "", - state: vendor.state ?? "", - zip: vendor.zip ?? "", - tradeSpecialties: vendor.tradeSpecialties ?? "", - isActive: vendor.isActive, - companyId: vendor.companyId ?? null, - preferredContact: vendor.preferredContact ?? "Phone", - }; -} - interface VendorDetailDrawerProps { open: boolean; vendor: VendorListItem | null; @@ -88,109 +36,20 @@ export function VendorDetailDrawer({ tradeOptions, }: VendorDetailDrawerProps) { const vendorId = vendor?.id; - const { data: facets } = useVendorFacets(); const { data: detail, isLoading, error, } = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined); - const updateVendor = useUpdateVendor(); - - const [submitError, setSubmitError] = useState(null); - const [original, setOriginal] = useState(null); - - const companies = useMemo(() => facets?.companies ?? [], [facets]); - - const { - control, - handleSubmit, - reset, - setValue, - trigger, - formState: { errors }, - } = useForm({ - resolver: zodResolver(vendorSchema), - mode: "onChange", - defaultValues: { - companyName: "", - contactName: "", - email: "", - phone: "", - companyPhone: "", - googleMapsUrl: "", - notes: "", - totalJobs: 0, - address: "", - city: "", - state: "", - zip: "", - tradeSpecialties: "", - isActive: true, - companyId: null, - preferredContact: "Phone", - }, - }); - const currentValues = useWatch({ control }); - const isFormValid = vendorSchema.safeParse(currentValues).success; - - useEffect(() => { - if (open && detail) { - const values = toFormValues(detail); - reset(values); - setOriginal(values); - setSubmitError(null); - } - if (!open) { - setOriginal(null); - setSubmitError(null); - } - }, [open, detail, reset]); - - const onCompanySelected = (company: (typeof companies)[number] | null) => { - if (!company) return; - const options = { shouldDirty: true, shouldValidate: true } as const; - setValue("companyId", company.companyId, options); - setValue("companyPhone", company.companyPhone, options); - setValue("email", company.email, options); - setValue("address", company.address, options); - setValue("city", company.city, options); - setValue("state", company.state, options); - setValue("zip", company.zip, options); - setValue("googleMapsUrl", company.googleMapsUrl, options); - void trigger(); - }; - - const handleCancel = () => { - if (original) { - reset(original); - } - setSubmitError(null); - onModeChange("view"); - }; - - const onSubmit = (values: VendorFormValues) => { - if (vendorId === undefined || vendorId === null || vendorId === "") return; - setSubmitError(null); - updateVendor.mutate( - { id: vendorId, values }, - { - onSuccess: () => { - onModeChange("view"); - }, - onError: (err: Error) => { - setSubmitError(err.message || "Failed to save vendor"); - }, - }, - ); - }; - + const drawerForm = useVendorDetailDrawerForm({ detail, open, vendorId, onModeChange }); const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties); const mapsUrl = detail?.googleMapsUrl; + const pending = drawerForm.updateVendor.isPending; return ( - `1px solid ${theme.palette.divider}`, - }} - > - - - {getInitials(vendor?.contactName ?? "")} - - - - {vendor?.companyName || "Vendor"} - - - {mode === "edit" ? "Editing vendor" : "Vendor details"} - - - - - - - - + {isLoading ? ( @@ -263,188 +73,31 @@ export function VendorDetailDrawer({ ) : detail ? ( <> {mode === "view" ? ( - - {Boolean(submitError) && {submitError}} - - - - {Boolean(trades[0]) && ( - 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]} - /> - )} - - - - - - - - - - - - - - - - - {Boolean(mapsUrl) && ( - - - Open in Google Maps - - )} - - - - {trades.length === 0 ? ( - - No trades specified. - - ) : ( - - {trades.map((trade, idx) => ( - - ))} - - )} - - - - - - - {Boolean(detail.notes) && ( - - - {detail.notes} - - - )} - + ) : ( - - - {Boolean(submitError) && {submitError}} - - ( - - } - label={field.value ? "Active" : "Inactive"} - sx={{ - alignSelf: "flex-start", - }} - /> - )} - /> - - - - + )} - - `1px solid ${theme.palette.divider}`, - }} - > - {mode === "view" ? ( - <> - {Boolean(mapsUrl) && ( - - - - )} - - - - ) : ( - <> - - - - )} - + onModeChange("edit")} + /> ) : ( @@ -457,30 +110,3 @@ export function VendorDetailDrawer({ ); } - -function DetailSection({ title, children }: { title: string; children: ReactNode }) { - return ( - - - {title} - - {children} - - ); -} - -function DetailField({ label, value }: { label: string; value: string }) { - const display = value && value.trim() !== "" ? value : "—"; - return ( - - - {label} - - {display} - - ); -} 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. + +
+ + +