diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index e5c8bcf3..8bbfdfb8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -306,18 +306,11 @@ test.describe("Vendor directory prototype parity", () => { test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { - await page.setViewportSize({ width: 1440, height: 1000 }); const state = await mockVendorApi(page); await page.goto("/vendors"); - const heading = page.getByRole("heading", { name: "Vendors / Technicians" }); - await expect(heading).toBeVisible(); - await expect(heading).toHaveCSS("font-family", /Montserrat/); - await expect(heading).toHaveCSS("font-size", "30px"); - await expect(heading).toHaveCSS("font-weight", "800"); - await expect(heading).toHaveCSS("line-height", "33px"); - await expect(heading).toHaveCSS("letter-spacing", "-0.6px"); - await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); + await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", @@ -330,58 +323,52 @@ test.describe("Vendor directory prototype parity", () => { "Phone", "Email", "Total Jobs", + "Status", ]) { await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); } - await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); - const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox(); - expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 }); - const tableContainerBox = await page.locator("table").locator("..").boundingBox(); - expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); - expect(tableContainerBox?.height).toBeCloseTo(738, 0); - expect((await page.locator("table").boundingBox())?.width).toBe(1550); - expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe( - 47, - ); - expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); await expect(page.getByText("+1")).toBeVisible(); - await expect(page.getByText("2 active technicians")).toBeVisible(); - await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); + await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); - await page.getByPlaceholder("Search name, company, trade…").fill("metro"); + await page.getByPlaceholder("Search name, company, trade...").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); - await page.getByPlaceholder("Search name, company, trade…").fill("clayton"); + await page.getByPlaceholder("Search name, company, trade...").fill("clayton"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await page.getByPlaceholder("Search name, company, trade…").clear(); + await page.getByPlaceholder("Search name, company, trade...").clear(); await page.getByRole("button", { name: "Open vendor filters" }).click(); await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png"); await page.getByRole("button", { name: /Company/ }).click(); await page.getByPlaceholder("All companies").click(); await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); - await page.getByRole("button", { name: "Apply filters" }).click(); + await page.getByRole("button", { name: "Apply" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText("1 technician · filtered by")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", ), ).toBe(true); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( + "history will be preserved", + ); + await page.getByRole("button", { name: "Deactivate", exact: true }).click(); + await expect.poll(() => state.deletedId).toBe("1"); + await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No inactive technicians found")).toBeVisible(); + await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); - await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); @@ -391,35 +378,33 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - await page.getByRole("button", { name: "Add Vendor", exact: true }).click(); - await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible(); - await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); + await page.getByRole("button", { name: "+ Add Vendor" }).click(); await expect( - page.getByRole("button", { name: "Add Vendor", exact: true }).last(), - ).toBeEnabled(); + page.getByText("Register a company and add zero, one, or multiple technicians."), + ).toBeVisible(); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); + await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); - await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); - await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( + await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); + await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue( "dispatch@gateway.test", ); - await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St"); - await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue( - "https://maps.google.com/gateway", - ); + await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); + await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); - await page.getByLabel("Technician name (optional)").last().fill("New Technician"); + await page.getByLabel("Technician Name").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); await tradeInput.fill("HVAC"); await tradeInput.press("Enter"); await tradeInput.fill("Plumbing"); await tradeInput.press("Enter"); await page.getByRole("button", { name: "Move Plumbing up" }).click(); - await page.getByLabel("Notes (optional)").fill("Created in browser E2E"); - await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); - await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); + await page.getByLabel("Notes").fill("Created in browser E2E"); + await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); + await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0); expect(state.updatedBody).toMatchObject({ name: "Gateway Plumbing", companyPhone: "(314) 555-0100", @@ -444,7 +429,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible(); + await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); await expect( @@ -453,9 +438,9 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors$/); - await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E"); - await detailDrawer.getByRole("button", { name: "Save changes" }).click(); + await expect(page).toHaveURL(/\/vendors\/edit\/1$/); + await page.getByLabel("Notes").fill("Updated in browser E2E"); + await page.getByRole("button", { name: "Save" }).click(); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); @@ -463,18 +448,18 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); - await page.getByRole("switch", { name: "Active status" }).click(); + 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: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await detailDrawer.getByRole("switch", { name: "Active status" }).click(); - await detailDrawer.getByRole("button", { name: "Save changes" }).click(); + await detailDrawer.getByRole("button", { name: "Edit" }).click(); + await expect(page).toHaveURL(/\/vendors\/edit\/1$/); + await page.getByRole("switch", { name: "Inactive" }).first().click(); + await page.getByRole("button", { name: "Save" }).click(); await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); - await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); await page.getByRole("tab", { name: "Active (2)", exact: true }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); @@ -483,7 +468,7 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - const addButton = page.getByRole("button", { name: "Add Vendor", exact: true }); + const addButton = page.getByRole("button", { name: "+ Add Vendor" }); await addButton.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); @@ -492,12 +477,10 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); - await expect( - page.getByRole("button", { name: "Add Vendor", exact: true }).last(), - ).toBeEnabled(); + await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); - await page.getByLabel("Company Phone (optional)").fill("3145550199"); - await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); + await page.getByLabel("Company Phone").fill("3145550199"); + await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); @@ -523,8 +506,7 @@ test.describe("Vendor directory prototype parity", () => { const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); - await page.getByRole("switch", { name: "Active status" }).click(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); @@ -535,8 +517,7 @@ test.describe("Vendor directory prototype parity", () => { const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); - await page.getByRole("switch", { name: "Active status" }).click(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); await page .getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("button", { @@ -560,7 +541,6 @@ test.describe("Vendor directory prototype parity", () => { /Vendor directory unavailable|failed|server error/i, { timeout: 15_000 }, ); - await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png"); await page.getByRole("button", { name: "Open vendor filters" }).click(); const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" }); await expect(filterDrawer).toBeVisible(); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index f43d1e07..d175e6c5 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -38,6 +38,11 @@ export const API_PATHS = { mediaItem: (id: string | number, mediaId: string | number) => `workorders/${id}/media/${mediaId}`, completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, + uplifts: (id: string | number) => `workorders/${id}/uplifts`, + upliftCancel: (workOrderId: string | number, upliftId: string | number) => + `workorders/${workOrderId}/uplifts/${upliftId}/cancel`, + upliftRevoke: (workOrderId: string | number, upliftId: string | number) => + `workorders/${workOrderId}/uplifts/${upliftId}/revoke`, lookupsDispatchers: "workorders/lookups/dispatchers", }, pmSchedule: { diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts index b63291be..9b6f3b0d 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -15,7 +15,7 @@ export interface VendorDeactivationState { confirm: () => void; } -export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState { +export function useVendorDeactivation(): VendorDeactivationState { const [target, setTarget] = useState(null); const [error, setError] = useState(null); const deleteVendor = useDeleteVendor(); @@ -39,10 +39,7 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio if (!target || target.id == null) return; setError(null); deleteVendor.mutate(target.id, { - onSuccess: () => { - setTarget(null); - onSuccess?.(); - }, + onSuccess: () => setTarget(null), onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), }); }; diff --git a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts index fdc90685..9385a834 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -31,7 +31,7 @@ export interface VendorListState { export function useVendorListState(): VendorListState { const [search, setSearch] = useState(""); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(48); + const [pageSize, setPageSize] = useState(12); const [isActive, setIsActive] = useState(true); const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 6bf89b1f..f6ea637a 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from "react"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, - emptyRosterTechnician, vendorCompanyRosterSchema, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; @@ -21,30 +20,11 @@ import type { VendorRosterConflict, } from "@/domain/vendors/types/vendor"; -function withoutBlankNewTechnicians( - values: VendorCompanyRosterFormValues, -): VendorCompanyRosterFormValues { - return { - ...values, - technicians: values.technicians.filter( - (technician) => - technician.id != null || - Boolean( - technician.contactName.trim() || - technician.phone.trim() || - technician.email.trim() || - technician.tradeSpecialties.trim(), - ), - ), - }; -} - export interface VendorRosterFormProps { mode: "create" | "update"; vendorId?: string | number; companyId?: string | number; onSuccess?: (roster: VendorCompanyRoster) => void; - startWithTechnician?: boolean; } export interface VendorRosterForm { @@ -75,7 +55,6 @@ export function useVendorRosterForm({ vendorId, companyId, onSuccess, - startWithTechnician = false, }: VendorRosterFormProps): VendorRosterForm { const query = useVendorCompanyRoster( mode === "update" ? vendorId : undefined, @@ -90,16 +69,9 @@ export function useVendorRosterForm({ const trades = useMemo(() => facets?.trades ?? [], [facets]); const resolver = useVendorRosterResolver(routeRoster); - const createDefaults = useMemo( - () => ({ - ...emptyVendorCompanyRosterForm, - technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [], - }), - [startWithTechnician], - ); const form = useForm({ resolver, - defaultValues: createDefaults, + defaultValues: emptyVendorCompanyRosterForm, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, mode: "onChange", }); @@ -124,10 +96,10 @@ export function useVendorRosterForm({ }, [routeRoster, watched]); const resetForm = useCallback(() => { - reset(createDefaults); + reset(emptyVendorCompanyRosterForm); resetSelection(); clearConflict(); - }, [clearConflict, createDefaults, reset, resetSelection]); + }, [clearConflict, reset, resetSelection]); const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; const isUpdate = mode === "update" || selection.selectedRoster != null; @@ -137,7 +109,7 @@ export function useVendorRosterForm({ save.mutate( { mode: isUpdate ? "update" : "create", - values: withoutBlankNewTechnicians(formValues), + values: formValues, companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, originalRoster: mode === "update" ? routeRoster : undefined, diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 31804a3c..d0025662 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -8,8 +8,8 @@ import { DialogContent, DialogTitle, Stack, + Typography, } from "@mui/material"; -import { Text } from "@/components/ui/text"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; @@ -21,11 +21,7 @@ interface VendorCreateModalProps { } export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { - const form = useVendorRosterForm({ - mode: "create", - onSuccess: onClose, - startWithTechnician: true, - }); + const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); const { resetForm } = form; useEffect(() => { @@ -37,24 +33,17 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { open={open} onClose={form.isSaving ? undefined : onClose} fullWidth - maxWidth="sm" + maxWidth="md" aria-labelledby="vendor-create-dialog-title" - slotProps={{ - paper: { - role: "dialog", - sx: { maxHeight: "88vh", borderRadius: 2 }, - }, - }} + slotProps={{ paper: { role: "dialog" } }} > - - Add Vendor - - - Register a vendor company and its technicians. - + Add Vendor Company + + Register a company and add zero, one, or multiple technicians. + - + {form.loadErrorMessage !== null && ( @@ -84,9 +73,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { variant="contained" type="submit" form="vendor-create-form" - disabled={form.isSaving} + disabled={form.isSaving || !form.isValid} > - {form.isSaving ? : "Add Vendor"} + {form.isSaving ? : "Add Vendor Company"} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 74d6ef02..ad7ffa29 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, type ReactNode } from "react"; -import { Controller, useWatch } from "react-hook-form"; +import { useEffect, type ReactNode } from "react"; +import { useNavigate } from "react-router"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -13,22 +13,15 @@ import { Drawer, IconButton, Link, + List, + ListItem, Stack, - Switch, + Typography, } from "@mui/material"; -import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; -import { VendorRosterFormFields } from "./vendor-roster-form-fields"; -import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; -import { useVendorRosterForm } from "./use-vendor-roster-form"; -import { Text } from "@/components/ui/text"; -import type { - VendorCompanyRoster, - VendorListItem, - VendorRosterTechnician, -} from "@/domain/vendors/types/vendor"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; +import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; -const DRAWER_WIDTH = 420; +const DRAWER_WIDTH = 440; function splitTrades(value: string | undefined): string[] { return (value ?? "") @@ -40,45 +33,106 @@ function splitTrades(value: string | undefined): string[] { interface VendorDetailDrawerProps { open: boolean; vendor: VendorListItem | null; - startInEdit?: boolean; - onRequestDeactivation?: (vendor: VendorListItem) => void; onClose: () => void; } function SectionTitle({ children }: { children: ReactNode }) { return ( - {children} - + ); } -function Field({ label, value }: { label: string; value?: string }) { - const display = !value || value.trim() === "" ? "—" : value; +function Field({ label, value }: { label: string; value: string }) { + const display = value && value.trim() !== "" ? value : "—"; return ( - + {label} - - {display} + + {display} ); } +function TechnicianEntry({ + contactName, + phone, + email, + preferredContact, + tradeSpecialties, + isActive, + totalJobs, +}: { + contactName: string; + phone: string; + email: string; + preferredContact?: string; + tradeSpecialties: string; + isActive: boolean; + totalJobs: number; +}) { + const trades = splitTrades(tradeSpecialties); + return ( + + + + + {contactName || "—"} + + + + {totalJobs} jobs + + + + {Boolean(phone) && ( + + {phone} + + )} + {Boolean(email) && ( + + {email} + + )} + + + {Boolean(preferredContact) && ( + + Prefers {preferredContact} + + )} + {trades.map((trade, index) => ( + + ))} + + + + ); +} + function DrawerHeader({ roster, vendor, - technician, onClose, }: { roster?: VendorCompanyRoster; vendor: VendorListItem | null; - technician?: VendorRosterTechnician; onClose: () => void; }) { return ( @@ -87,18 +141,23 @@ function DrawerHeader({ sx={{ alignItems: "center", justifyContent: "space-between", - px: 2.5, + px: 3, py: 2, borderBottom: (theme) => `1px solid ${theme.palette.divider}`, }} > - - {technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"} - - - {roster?.name || vendor?.companyName || "Vendor details"} - + + {roster?.name || vendor?.companyName || "Vendor company"} + + + {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} + @@ -108,19 +167,18 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const address = [roster.address, roster.city, roster.state, roster.zip] - .filter(Boolean) - .join(", "); - + const mapsUrl = roster.googleMapsUrl; return ( - - - - {Boolean(roster.googleMapsUrl) && ( + + + {Boolean(mapsUrl) && ( - - - - - Trade - - - {trades.length > 0 ? ( - trades.map((trade, index) => ( - - )) - ) : ( - — - )} - - - - - {technician.totalJobs ?? 0} - - Total Jobs - - - - - Status - - Technicians + {roster.technicians.length === 0 ? ( + + No technicians assigned to this company. + + ) : ( + + {roster.technicians.map((technician) => ( + - - - + ))} + + )} ); } -function DrawerBody({ - roster, - technician, -}: { - roster: VendorCompanyRoster; - technician?: VendorRosterTechnician; -}) { +function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { return ( - - - Company - - + + Company + - {technician ? ( - - ) : ( - - No technicians assigned to this company. - - )} + {Boolean(roster.notes) && ( <> Notes - + {roster.notes} - + )} @@ -218,8 +246,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { `1px solid ${theme.palette.divider}`, alignItems: "center", justifyContent: "flex-end", @@ -232,143 +260,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { ); } -function DrawerEditor({ - vendor, - onClose, - onSaved, - onRequestDeactivation = () => undefined, -}: { - vendor: VendorListItem; - onClose: () => void; - onSaved: () => void; - onRequestDeactivation: (vendor: VendorListItem) => void; -}) { - const form = useVendorRosterForm({ - mode: "update", - vendorId: vendor.id ?? undefined, - companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined, - onSuccess: onSaved, - }); - const roster = form.roster; - const technicians = useWatch({ control: form.control, name: "technicians" }) ?? []; - const selectedIndex = technicians.findIndex( - (technician) => technician.id != null && String(technician.id) === String(vendor.id), - ); - const selectedTotalJobs = - roster?.technicians.find( - (technician) => technician.id != null && String(technician.id) === String(vendor.id), - )?.totalJobs ?? vendor.totalJobs; - - if (form.isLoading) { - return ; - } - if (form.isError || !roster) { - return ( - - Failed to load vendor company. Try again. - - ); - } - - return ( - - - {form.loadErrorMessage !== null && ( - - )} - {form.conflict !== null && ( - - )} - - {selectedIndex >= 0 && ( - <> - - - - {selectedTotalJobs} - - Total Jobs - - - ( - - {field.value ? "Active" : "Inactive"} - { - if (field.value && !checked) onRequestDeactivation(vendor); - else field.onChange(checked); - }} - /> - - )} - /> - - - )} - - - - - - - ); -} - -function resolveTechnician( - roster: VendorCompanyRoster | undefined, - vendor: VendorListItem | null, -): VendorRosterTechnician | undefined { - if (!roster || !vendor) return undefined; - - const match = roster.technicians.find( - (technician) => - vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id), - ); - if (match) return match; - if (!vendor.contactName) return undefined; - - return { - id: vendor.id ?? undefined, - contactName: vendor.contactName, - phone: vendor.phone, - email: vendor.email, - tradeSpecialties: vendor.tradeSpecialties, - isActive: vendor.isActive, - totalJobs: vendor.totalJobs, - }; -} - -export function VendorDetailDrawer({ - open, - vendor, - startInEdit = false, - onRequestDeactivation = () => undefined, - onClose, -}: VendorDetailDrawerProps) { - const [isEditing, setIsEditing] = useState(startInEdit); +export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { + const navigate = useNavigate(); const vendorId = vendor?.id; const companyId = vendor?.companyId; const hasVendorId = vendorId != null; @@ -381,30 +274,25 @@ export function VendorDetailDrawer({ !hasVendorId ? (companyId ?? undefined) : undefined, ); - const selectedTechnician = resolveTechnician(roster, vendor); - - useEffect(() => { - if (open) setIsEditing(startInEdit); - }, [open, startInEdit, vendor]); + const editHref = hasVendorId + ? `/vendors/edit/${vendorId}` + : `/vendors/company/${companyId ?? ""}`; useEffect(() => { if (!open) return; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape" && !isEditing) onClose(); + if (event.key === "Escape") onClose(); }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [isEditing, onClose, open]); + }, [onClose, open]); return ( { - if (reason === "escapeKeyDown" && isEditing) return; - onClose(); - }} + onClose={onClose} anchor="right" aria-labelledby="vendor-detail-drawer-title" ModalProps={{ keepMounted: false }} @@ -416,21 +304,9 @@ export function VendorDetailDrawer({ }} > - + - {isEditing && vendor ? ( - setIsEditing(false)} - onRequestDeactivation={onRequestDeactivation} - /> - ) : isLoading ? ( + {isLoading ? ( @@ -440,14 +316,14 @@ export function VendorDetailDrawer({ ) : roster ? ( <> - - setIsEditing(true)} /> + + navigate(editHref)} /> ) : ( - + No vendor selected. - + )} diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx deleted file mode 100644 index c9be7d45..00000000 --- a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import type { Dispatch, ReactNode, SetStateAction } from "react"; -import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; -import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; -import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Autocomplete, - Box, - Button, - Checkbox, - Chip, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { - VendorFacets, - VendorFacetCompany, - VendorFacetJobBucket, - VendorFacetLocation, -} from "@/domain/vendors/types/vendor"; -import type { VendorFilters } from "./vendor-filters"; - -type DraftSetter = Dispatch>; - -function FilterSection({ - title, - icon, - children, -}: { - title: string; - icon: ReactNode; - children: ReactNode; -}) { - return ( - - } aria-controls={`${title}-filters-content`}> - - {icon} - - {title} - - - - - {children} - - - ); -} - -function CompanyFilter({ - options, - draft, - setDraft, -}: { - options: VendorFacetCompany[]; - draft: VendorFilters; - setDraft: DraftSetter; -}) { - const allSelected = draft.companies.length === options.length && options.length > 0; - return ( - }> - - - Select vendor companies - - - - (typeof option === "string" ? option : option.name)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.name === value.name - } - value={options.filter((option) => draft.companies.includes(option.name))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetCompany)[]).map((entry) => - typeof entry === "string" ? entry : entry.name, - ); - setDraft((current) => ({ ...current, companies: next })); - }} - renderOption={(props, option, { selected }) => { - const { key, ...optionProps } = props; - return ( -
  • - - {option.name} -
  • - ); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.name; - const { key, ...chipProps } = getItemProps({ index }); - return ; - }) - } - renderInput={(params) => ( - - )} - /> -
    - ); -} - -function TradeFilter({ options, draft, setDraft }: FilterProps) { - return ( - }> - - setDraft((current) => ({ ...current, trades: value as string[] })) - } - renderValue={(value, getItemProps) => - value.map((option, index) => { - const { key, ...chipProps } = getItemProps({ index }); - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - ); -} - -interface FilterProps { - options: T[]; - draft: VendorFilters; - setDraft: DraftSetter; -} - -function LocationFilter({ options, draft, setDraft }: FilterProps) { - return ( - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.label === value.label - } - value={options.filter((option) => draft.locations.includes(option.label))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetLocation)[]).map((entry) => - typeof entry === "string" ? entry : entry.label, - ); - setDraft((current) => ({ ...current, locations: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const { key, ...chipProps } = getItemProps({ index }); - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - ); -} - -function JobCountFilter({ options, draft, setDraft }: FilterProps) { - return ( - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.id === value.id - } - value={options.filter((option) => draft.jobBuckets.includes(option.id))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => - typeof entry === "string" ? entry : entry.id, - ); - setDraft((current) => ({ ...current, jobBuckets: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const { key, ...chipProps } = getItemProps({ index }); - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - ); -} - -export function VendorFilterOptions({ - facets, - draft, - setDraft, -}: { - facets: VendorFacets | undefined; - draft: VendorFilters; - setDraft: DraftSetter; -}) { - return ( - <> - - - - - - ); -} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index e3b91cf5..ae4b51ba 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -1,9 +1,32 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; import CloseIcon from "@mui/icons-material/Close"; -import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material"; -import { VendorFilterOptions } from "./vendor-filter-options"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Box, + Button, + Checkbox, + Chip, + Drawer, + IconButton, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { + VendorFacets, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; -import type { VendorFacets } from "@/domain/vendors/types/vendor"; interface VendorFiltersDrawerProps { open: boolean; @@ -15,6 +38,32 @@ interface VendorFiltersDrawerProps { const DRAWER_WIDTH = 360; +function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} + export function VendorFiltersDrawer({ open, onClose, @@ -25,12 +74,23 @@ export function VendorFiltersDrawer({ const [draft, setDraft] = useState(appliedFilters); useEffect(() => { - if (open) setDraft(appliedFilters); + if (open) { + setDraft(appliedFilters); + } }, [open, appliedFilters]); + const companyOptions = useMemo(() => facets?.companies ?? [], [facets]); + const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]); + const locationOptions = useMemo(() => facets?.locations ?? [], [facets]); + const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]); + const activeCount = draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; + const handleClear = () => { + setDraft(emptyVendorFilters); + }; + const handleApply = () => { onApply(draft); onClose(); @@ -73,7 +133,165 @@ export function VendorFiltersDrawer({
    - + }> + + + Select vendor companies + + + + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={companyOptions.filter((option) => draft.companies.includes(option.name))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetCompany)[]).map((entry) => + typeof entry === "string" ? entry : entry.name, + ); + setDraft((current) => ({ ...current, companies: next })); + }} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={locationOptions.filter((option) => draft.locations.includes(option.label))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetLocation)[]).map((entry) => + typeof entry === "string" ? entry : entry.label, + ); + setDraft((current) => ({ ...current, locations: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => + typeof entry === "string" ? entry : entry.id, + ); + setDraft((current) => ({ ...current, jobBuckets: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    `1px solid ${theme.palette.divider}`, }} > -
    diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index d6d5cbf7..32f91675 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -28,7 +28,6 @@ interface VendorRosterFormFieldsProps { selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; - showTechnicianStatus?: boolean; } function CompanyNameField({ @@ -55,7 +54,6 @@ function CompanyNameField({ )} @@ -122,7 +119,10 @@ function CompanyFields({ }: VendorRosterFormFieldsProps) { return ( - + + Company + + ( field.onChange(formatNorthAmericanPhone(event.target.value))} error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message} + helperText={errors.companyPhone?.message ?? "Phone or email required"} fullWidth /> )} @@ -155,8 +155,7 @@ function CompanyFields({ render={({ field }) => ( ( - - )} + render={({ field }) => } /> - + ( )} @@ -214,14 +206,7 @@ function CompanyFields({ control={control} name="notes" render={({ field }) => ( - + )} /> @@ -236,7 +221,6 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; - showStatus: boolean; } function TechnicianRow({ @@ -246,13 +230,12 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, - showStatus, }: TechnicianRowProps) { return ( - {index + 1} - TECHNICIAN + Technician {index + 1} @@ -273,8 +256,7 @@ function TechnicianRow({ render={({ field }) => ( ( field.onChange(formatNorthAmericanPhone(event.target.value))} @@ -310,29 +292,26 @@ function TechnicianRow({ error={Boolean(error?.email)} helperText={error?.email?.message} fullWidth - sx={{ display: "none" }} /> )} /> - {showStatus && ( - ( - field.onChange(checked)} - /> - } - label={field.value ? "Active" : "Inactive"} - /> - )} - /> - )} + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> ); } @@ -341,12 +320,10 @@ function TechniciansFieldArray({ control, errors, tradeOptions, - showStatus, }: { control: Control; errors: FieldErrors; tradeOptions: string[]; - showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -389,7 +366,6 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} - showStatus={showStatus} /> )) )} @@ -399,17 +375,12 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; + const { control, errors, tradeOptions = [] } = props; return ( - + ); } diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index a8203ecb..e3e7cfa5 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -1,9 +1,13 @@ -import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material"; -import { Plus, SlidersHorizontal } from "lucide-react"; -import { PageHeader } from "@/components/ui/page-header"; -import { SearchInput } from "@/components/ui/search-input"; -import { Text } from "@/components/ui/text"; -import { ToolbarButton } from "@/components/ui/toolbar-button"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import { + Button, + Chip, + CircularProgress, + Stack, + TablePagination, + TextField, + Typography, +} from "@mui/material"; interface VendorsListToolbarProps { globalCount: number; @@ -29,44 +33,52 @@ export function VendorsListToolbar({ onAddVendor, }: VendorsListToolbarProps) { return ( - - - } - onClick={onOpenFilters} - aria-label="Open vendor filters" - badge={appliedFilterCount} - className="w-[88.609375px] text-[13px]" - > - Filters - - } - onClick={onAddVendor} - className="w-[115.546875px] border-0 px-3 text-[13px]" - > - Add Vendor - - {isFetching && !isLoading && ( - + + + + Vendors / Technicians + + + {globalCount} vendor entries · {activeCount} active + + {isFetching && !isLoading && } + + + + onSearchChange(e.target.value)} + placeholder="Search name, company, trade..." + className="min-w-[260px]" + /> + + + + ); } @@ -78,21 +90,16 @@ interface AppliedChipEntry { interface VendorAppliedFiltersBarProps { chips: AppliedChipEntry[]; - resultCount: number; onClearAll: () => void; } -export function VendorAppliedFiltersBar({ - chips, - resultCount, - onClearAll, -}: VendorAppliedFiltersBarProps) { +export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { if (chips.length === 0) return null; return ( - - {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by - + + Applied: + {chips.map((chip) => ( - + {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "technician" : "technicians"} + {totalCount === 1 ? "vendor entry" : "vendor entries"} {isFiltered && " · filtered"} - - - {activeCount} active · {inactiveCount} inactive - - {totalCount > pageSize && ( - onPageChange(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} - rowsPerPageOptions={[24, 48, 96]} - /> - )} + {` · ${activeCount} active · ${inactiveCount} inactive`} + + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[12, 24, 48]} + /> ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index db588cff..802fff8a 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,10 +1,11 @@ import { type KeyboardEvent, type MouseEvent } from "react"; -import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; +import { useNavigate } from "react-router"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Avatar, - Box, + Chip, CircularProgress, IconButton, Link, @@ -17,8 +18,8 @@ import { TableHead, TableRow, Tooltip, + Typography, } from "@mui/material"; -import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ @@ -29,6 +30,9 @@ const TABLE_COLUMNS = [ "Phone", "Email", "Total Jobs", + "Status", + "View", + "Edit", ] as const; function getInitials(name: string): string { @@ -50,34 +54,19 @@ function stopPropagation(event: MouseEvent): void { event.stopPropagation(); } -function VendorStatus({ isActive }: { isActive: boolean }) { - return ( - - - {isActive ? "Active" : "Inactive"} - - ); -} - interface VendorTableRowProps { row: VendorListItem; onOpenDetail: (row: VendorListItem) => void; - onOpenEdit: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; } -function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { +function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { + const navigate = useNavigate(); const trades = splitTrades(row.tradeSpecialties); const primaryTrade = trades[0] ?? "—"; const extraTrades = trades.length > 1 ? trades.length - 1 : 0; const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; + const hasVendorId = row.id != null; const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); const handleKeyDown = (event: KeyboardEvent) => { @@ -99,62 +88,37 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) onKeyDown={handleKeyDown} > - - {row.companyName || "—"} - + {row.companyName || "—"} - + {getInitials(row.contactName)} - - {row.contactName || "—"} - + {row.contactName || "—"} - {locationText} - + {locationText} + {primaryTrade === "—" ? ( "—" ) : ( {primaryTrade} - {extraTrades > 0 && ( - - +{extraTrades} - - )} + {extraTrades > 0 && } )} - + {row.phone ? ( - + {row.phone} ) : ( @@ -163,53 +127,71 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {row.email ? ( - + {row.email} ) : ( "—" )} - {row.totalJobs ?? 0} + {row.totalJobs ?? 0} - + - + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + + + - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - { event.stopPropagation(); - onOpenEdit(row); + navigate( + row.id != null + ? `/vendors/edit/${row.id}` + : `/vendors/company/${row.companyId ?? ""}`, + ); }} > + {hasVendorId && row.isActive && ( + + { + event.stopPropagation(); + onOpenDeactivation(row); + }} + > + + + + )} @@ -218,118 +200,50 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) interface VendorsTableProps { rows: VendorListItem[]; - isActive: boolean; - isFiltered: boolean; isLoading: boolean; showError: boolean; onOpenDetail: (row: VendorListItem) => void; - onOpenEdit: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; } export function VendorsTable({ rows, - isActive, - isFiltered, isLoading, showError, onOpenDetail, - onOpenEdit, + onOpenDeactivation, }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; - const columnCount = TABLE_COLUMNS.length + 2; return ( - - - - - - - - - - - - - + +
    {TABLE_COLUMNS.map((column) => ( - + {column} ))} - Status - {isLoading ? ( - + ) : showEmptyRow ? ( - - - - - {`No ${isActive ? "active" : "inactive"} technicians found`} - - - {isFiltered - ? "Try a different search, or add a new vendor." - : "Add a vendor to get started."} - - + + + No vendors match the current filters. + ) : ( @@ -338,7 +252,7 @@ export function VendorsTable({ key={String(row.id ?? `company-${row.companyId ?? ""}`)} row={row} onOpenDetail={onOpenDetail} - onOpenEdit={onOpenEdit} + onOpenDeactivation={onOpenDeactivation} /> )) )} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index 48447ad6..a084b59b 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -23,91 +23,16 @@ interface AppliedChip { label: string; } -function statusTabLabel(label: string, count: number) { - return ( - - {label} - - {count} - - - ); -} - -function VendorStatusTabs({ - isActive, - activeCount, - inactiveCount, - onChange, -}: { - isActive: boolean; - activeCount: number; - inactiveCount: number; - onChange: (isActive: boolean) => void; -}) { - return ( - onChange(value === "active")} - aria-label="Vendor status" - sx={{ - minHeight: 36, - flexShrink: 0, - borderBottom: (theme) => `1px solid ${theme.palette.divider}`, - backgroundColor: "var(--color-bg-surface)", - mx: -2, - px: 1.5, - "& .MuiTab-root": { - minHeight: 36, - px: 2, - py: 0, - fontSize: 13, - fontWeight: 500, - textTransform: "none", - }, - "& .MuiTab-root:first-of-type": { width: 102.84375 }, - "& .Mui-selected": { fontWeight: 600 }, - "& .MuiTabs-indicator": { height: 2 }, - }} - > - - - - ); -} - function describeChips( filters: VendorFilters, jobBucketLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; - filters.companies.forEach((value) => - chips.push({ category: "companies", value, label: `Company: ${value}` }), - ); - filters.trades.forEach((value) => - chips.push({ category: "trades", value, label: `Trade: ${value}` }), - ); - filters.locations.forEach((value) => - chips.push({ category: "locations", value, label: `Location: ${value}` }), - ); + filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value })); + filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); + filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value })); filters.jobBuckets.forEach((value) => - chips.push({ - category: "jobBuckets", - value, - label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`, - }), + chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), ); return chips; } @@ -117,12 +42,7 @@ export default function VendorsListPage() { const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [detailTarget, setDetailTarget] = useState(null); - const [detailStartsInEdit, setDetailStartsInEdit] = useState(false); - const closeDetail = () => { - setDetailTarget(null); - setDetailStartsInEdit(false); - }; - const deactivation = useVendorDeactivation(closeDetail); + const deactivation = useVendorDeactivation(); const { data: facets } = useVendorFacets(filters.isActive); const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); @@ -141,7 +61,7 @@ export default function VendorsListPage() { const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); return ( - + setCreateOpen(true)} /> - + + filters.setIsActiveWithReset(value === "active") + } + aria-label="Vendor status" + > + + + ({ key: `${chip.category}-${chip.value}`, label: chip.label, @@ -179,18 +102,10 @@ export default function VendorsListPage() { 0 || Boolean(filters.debouncedSearch)} isLoading={isLoading} showError={Boolean(error)} - onOpenDetail={(row) => { - setDetailStartsInEdit(false); - setDetailTarget(row); - }} - onOpenEdit={(row) => { - setDetailStartsInEdit(true); - setDetailTarget(row); - }} + onOpenDetail={setDetailTarget} + onOpenDeactivation={deactivation.open} /> setDetailTarget(null)} /> void; +}; + +export function UpliftCell({ summary, onOpen }: UpliftCellProps) { + const label = getUpliftCellLabel(summary); + + if (!onOpen) { + return label ? ( + {label} + ) : ( + — + ); + } + + const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus; + const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null; + + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index 59aca8f5..ef5ce6d4 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -16,6 +16,7 @@ type UseWoTableRowHandlersOptions = { onWeekChange: (mondayIso: string) => void; onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; onOpenCompDoc?: (row: WorkOrderTableRow) => void; + onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; onScheduleHighlight: (id: string | number | null) => void; @@ -27,6 +28,7 @@ export function useWoTableRowHandlers({ onWeekChange, onOpenSlideOver, onOpenCompDoc, + onOpenUplifts, onOpenMedia, onPatch, onScheduleHighlight, @@ -60,6 +62,12 @@ export function useWoTableRowHandlers({ onOpenCompDoc(row); } : undefined, + onOpenUplifts: onOpenUplifts + ? (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenUplifts(row); + } + : undefined, onOpenMedia: (row: WorkOrderTableRow) => { tableData.setSelectedId(row.id); onOpenMedia(row); @@ -135,6 +143,7 @@ export function useWoTableRowHandlers({ onWeekChange, onOpenSlideOver, onOpenCompDoc, + onOpenUplifts, onOpenMedia, onPatch, onScheduleHighlight, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts index 837c8622..7131622a 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts @@ -12,8 +12,9 @@ export const WO_TABLE_COLS = [ { key: "appt", label: "APPT TIME", w: 128 }, { key: "status", label: "STATUS", w: 148 }, { key: "doc", label: "COMP DOC", w: 84 }, + { key: "uplift", label: "UPLIFT", w: 108 }, { key: "actions", label: "", w: 100 }, ] as const; -export const WO_TABLE_MIN_WIDTH = 1504; +export const WO_TABLE_MIN_WIDTH = 1612; export const WO_TABLE_ROW_HEIGHT = 52; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index a9052764..47c1d035 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -167,6 +167,10 @@ export function WoTableInlineRowCells({ + + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 04c3a231..96bcfc67 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -4,7 +4,9 @@ import { ApptTimeCell } from "./cells/appt-time-cell"; import { CompDocCell } from "./cells/comp-doc-cell"; import { PMTypeCell } from "./cells/pm-type-cell"; import { StatusCell } from "./cells/status-cell"; +import { UpliftCell } from "./cells/uplift-cell"; import { VendorCell } from "./cells/vendor-cell"; +import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils"; import type { WoTableRowHandlers } from "./wo-table-row"; type WoTableRowServiceCellsProps = { @@ -99,6 +101,16 @@ export function WoTableRowServiceCells({ onOpenMedia={() => handlers.onOpenMedia(row)} /> + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index b3ba685f..4891de35 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -21,6 +21,7 @@ export type WoTableRowHandlers = { onView: (row: WorkOrderTableRow) => void; onEdit: (row: WorkOrderTableRow) => void; onOpenCompDoc?: (row: WorkOrderTableRow) => void; + onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; onPatch: (id: string | number, patch: Partial) => void; onScheduleDate: (id: string | number, date: string) => void; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index e94c3ad7..4d41e8fe 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -33,6 +33,7 @@ type WoTableProps = { onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; /** Omit until completion-doc slice mounts CompDocDialog. */ onOpenCompDoc?: (row: WorkOrderTableRow) => void; + onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; onClearSearch?: () => void; onOpenAdvancedFilters?: () => void; @@ -49,6 +50,7 @@ export function WoTable({ onWeekChange, onOpenSlideOver, onOpenCompDoc, + onOpenUplifts, onOpenMedia, onClearSearch, onOpenAdvancedFilters, @@ -67,6 +69,7 @@ export function WoTable({ onWeekChange, onOpenSlideOver, onOpenCompDoc, + onOpenUplifts, onOpenMedia, onPatch, onScheduleHighlight: setScheduleHighlightId, diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx new file mode 100644 index 00000000..02fc6944 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx @@ -0,0 +1,46 @@ +import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; +import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; +import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +type WorkOrdersListOverlaysProps = { + activeUpliftsRow: WorkOrderTableRow | null; + onCloseUplifts: () => void; + activeMediaRow: WorkOrderTableRow | null; + onCloseMedia: () => void; + mediaFiles: Record; + onMediaChange: (rowId: string | number, files: MediaFile[]) => void; +}; + +export function WorkOrdersListOverlays({ + activeUpliftsRow, + onCloseUplifts, + activeMediaRow, + onCloseMedia, + mediaFiles, + onMediaChange, +}: WorkOrdersListOverlaysProps) { + return ( + <> + {activeUpliftsRow != null && ( + + )} + + {activeMediaRow != null && ( + { + if (!open) onCloseMedia(); + }} + uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA} + onChange={(files) => onMediaChange(activeMediaRow.id, files)} + /> + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx new file mode 100644 index 00000000..f5cee496 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, +} from "@mui/material"; +import { Text } from "@/components/ui/text"; + +type RevokeUpliftDialogProps = { + open: boolean; + onClose: () => void; + onConfirm: (reason: string) => void; + pending?: boolean; + requireReason?: boolean; + title?: string; + description?: string; +}; + +export function RevokeUpliftDialog({ + open, + onClose, + onConfirm, + pending = false, + requireReason = false, + title = "Revoke auto-approved uplift?", + description = "This will mark the uplift as revoked. Optional reason is stored with the decision.", +}: RevokeUpliftDialogProps) { + const [reason, setReason] = useState(""); + + // Clear only after the dialog closes (parent closes on mutation success). + useEffect(() => { + if (!open) { + setReason(""); + } + }, [open]); + + const handleClose = () => { + onClose(); + }; + + const handleConfirm = () => { + onConfirm(reason.trim()); + }; + + const reasonMissing = requireReason && reason.trim() === ""; + + return ( + + {title} + + + {description} + + setReason(event.target.value)} + /> + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx new file mode 100644 index 00000000..f9de4392 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { Button, TextField } from "@mui/material"; +import { Text } from "@/components/ui/text"; + +type WorkOrderUpliftCreateFormProps = { + pending?: boolean; + onSubmit: (input: { amount: number; notes: string }) => void | Promise; +}; + +export function WorkOrderUpliftCreateForm({ + pending = false, + onSubmit, +}: WorkOrderUpliftCreateFormProps) { + const [amount, setAmount] = useState(""); + const [notes, setNotes] = useState(""); + + const parsedAmount = Number(amount); + const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0; + + const handleSubmit = async () => { + try { + await onSubmit({ amount: parsedAmount, notes: notes.trim() }); + setAmount(""); + setNotes(""); + } catch { + // Mutation onError already toasts; keep form values after failure. + } + }; + + return ( +
    + + Request uplift + +
    + setAmount(event.target.value)} + slotProps={{ htmlInput: { min: 0, step: "0.01" } }} + /> + setNotes(event.target.value)} + /> + + +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx new file mode 100644 index 00000000..795209b0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx @@ -0,0 +1,82 @@ +import { Button } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; +import { + canRevokeWorkOrderUplift, + formatUpliftMoney, + formatUpliftStatusLabel, + getUpliftStatusPillStyle, +} from "@/domain/work-orders/utils/uplift-display-utils"; + +type WorkOrderUpliftListItemProps = { + uplift: WorkOrderUplift; + readOnly: boolean; + isAdmin: boolean; + currentUserId: string | number | null | undefined; + pendingAction?: boolean; + onCancel?: () => void; + onRevoke?: () => void; +}; + +export function WorkOrderUpliftListItem({ + uplift, + readOnly, + isAdmin, + currentUserId, + pendingAction = false, + onCancel, + onRevoke, +}: WorkOrderUpliftListItemProps) { + const pillStyle = getUpliftStatusPillStyle(uplift.status); + const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel); + const showRevoke = + !readOnly && canRevokeWorkOrderUplift(uplift, currentUserId, isAdmin) && Boolean(onRevoke); + + return ( +
    +
    + + {formatUpliftStatusLabel(uplift.status)} + + + {formatUpliftMoney(uplift.amount)} + +
    + {Boolean(uplift.notes) && ( + + {uplift.notes} + + )} + {Boolean(uplift.requestedByName) && ( + + Requested by {uplift.requestedByName} + + )} + {showCancel && ( + + )} + {showRevoke && ( + + )} +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx new file mode 100644 index 00000000..2658376e --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx @@ -0,0 +1,89 @@ +import { CircularProgress } from "@mui/material"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { Text } from "@/components/ui/text"; +import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form"; +import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item"; +import type { + CreateWorkOrderUpliftInput, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; + +type WorkOrderUpliftsDialogContentProps = { + readOnly: boolean; + isAdmin: boolean; + currentUserId: string | number | null | undefined; + readOnlyStatusLabel?: string; + isLoading: boolean; + error: Error | null; + uplifts: WorkOrderUplift[]; + actionPending: boolean; + createPending: boolean; + onRetry: () => void; + onCreate: (input: CreateWorkOrderUpliftInput) => void | Promise; + onCancelUplift: (upliftId: string | number) => void; + onRevokeUplift: (upliftId: string | number) => void; +}; + +export function WorkOrderUpliftsDialogContent({ + readOnly, + isAdmin, + currentUserId, + readOnlyStatusLabel, + isLoading, + error, + uplifts, + actionPending, + createPending, + onRetry, + onCreate, + onCancelUplift, + onRevokeUplift, +}: WorkOrderUpliftsDialogContentProps) { + return ( + <> + {readOnly && ( + + This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only. + + )} + + {Boolean(error) && ( + + )} + + {isLoading && ( +
    + +
    + )} + + {!isLoading && !error && uplifts.length === 0 && ( + + No uplift requests yet. + + )} + + {!isLoading && + uplifts.map((uplift) => ( + onCancelUplift(uplift.id)} + onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)} + /> + ))} + + {!readOnly && !isLoading && ( + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx new file mode 100644 index 00000000..488df1f7 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx @@ -0,0 +1,121 @@ +import { useMemo, useState } from "react"; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog"; +import { WorkOrderUpliftsDialogContent } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift"; +import { + useCancelWorkOrderUplift, + useCreateWorkOrderUplift, + useRevokeWorkOrderUplift, + useWorkOrderUplifts, +} from "@/domain/work-orders/use-cases/use-work-order-uplifts"; +import { + isWorkOrderUpliftsReadOnly, + upliftRevokeRequiresReason, +} from "@/domain/work-orders/utils/uplift-display-utils"; +import { isAdminUser } from "@/lib/auth/user-utils"; +import { useAuthContext } from "@/providers/auth-context"; + +type RevokeTarget = { + id: string | number; + status: WorkOrderUpliftStatus; +}; + +type WorkOrderUpliftsDialogProps = { + row: WorkOrderTableRow | null; + open: boolean; + onClose: () => void; +}; + +export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) { + const { user } = useAuthContext(); + const isAdmin = isAdminUser(user?.userRoles); + const workOrderId = row?.id ?? null; + const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true; + const { + data: uplifts = [], + isLoading, + error, + refetch, + } = useWorkOrderUplifts(open ? workOrderId : null); + const createUplift = useCreateWorkOrderUplift(workOrderId ?? ""); + const cancelUplift = useCancelWorkOrderUplift(workOrderId ?? ""); + const revokeUplift = useRevokeWorkOrderUplift(workOrderId ?? ""); + const [revokeTarget, setRevokeTarget] = useState(null); + + const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending; + + const revokeDialogCopy = useMemo(() => { + if (revokeTarget?.status === "approved") { + return { + title: "Revoke approved uplift?", + description: + "Admin revoke requires a reason. This will mark the uplift as revoked and store your note with the decision.", + }; + } + return { + title: "Revoke auto-approved uplift?", + description: + "This will mark the uplift as revoked. Optional reason is stored with the decision.", + }; + }, [revokeTarget?.status]); + + const handleRevokeUplift = (upliftId: string | number) => { + const uplift = uplifts.find((item) => String(item.id) === String(upliftId)); + if (!uplift) { + return; + } + setRevokeTarget({ id: upliftId, status: uplift.status }); + }; + + return ( + <> + + Uplifts — WO {row?.woNumber ?? ""} + + void refetch()} + onCreate={async (input) => { + await createUplift.mutateAsync(input); + }} + onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)} + onRevokeUplift={handleRevokeUplift} + /> + + + + + + + setRevokeTarget(null)} + onConfirm={(reason) => { + if (revokeTarget == null) { + return; + } + revokeUplift.mutate( + { upliftId: revokeTarget.id, reason }, + { onSuccess: () => setRevokeTarget(null) }, + ); + }} + /> + + ); +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 8e0173e6..0d039df2 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -3,7 +3,7 @@ import { Box } from "@mui/material"; import { useNavigate } from "react-router"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorPanel } from "@/components/ui/query-error-panel"; -import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; +import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays"; import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; @@ -18,7 +18,7 @@ import { useWorkOrderVendors, } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; -import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import { useAuthContext } from "@/providers/auth-context"; @@ -28,6 +28,7 @@ export default function WorkOrdersListPage() { const { user } = useAuthContext(); const [wizardOpen, setWizardOpen] = useState(false); const [mediaRow, setMediaRow] = useState(null); + const [upliftsRow, setUpliftsRow] = useState(null); const [mediaFiles, setMediaFiles] = useState>({}); const filters = useWorkOrdersListFilters(); const tableData = useWorkOrdersTableData(filters); @@ -55,6 +56,11 @@ export default function WorkOrdersListPage() { const totalCount = tableData.totalCount; const scopeTotalCount = tableData.scopeTotalCount; + const activeUpliftsRow = useMemo(() => { + if (!upliftsRow) return null; + return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow; + }, [upliftsRow, tableData.rows]); + const activeMediaRow = useMemo(() => { if (!mediaRow) return null; return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow; @@ -129,6 +135,7 @@ export default function WorkOrdersListPage() { navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`); }} onOpenMedia={setMediaRow} + onOpenUplifts={setUpliftsRow} onClearSearch={() => filters.handleSearchChange("")} onOpenAdvancedFilters={() => filters.setAdvOpen(true)} onPatch={patchField} @@ -148,20 +155,17 @@ export default function WorkOrdersListPage() { - {activeMediaRow != null && ( - { - if (!open) setMediaRow(null); - }} - uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA} - onChange={(files) => { - setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files })); - patchField(activeMediaRow.id, { mediaCount: files.length }); - }} - /> - )} + setUpliftsRow(null)} + activeMediaRow={activeMediaRow} + onCloseMedia={() => setMediaRow(null)} + mediaFiles={mediaFiles} + onMediaChange={(rowId, files) => { + setMediaFiles((prev) => ({ ...prev, [String(rowId)]: files })); + patchField(rowId, { mediaCount: files.length }); + }} + /> ); } diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index 23c273cc..566ae837 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -49,17 +49,10 @@ export function PageHeader({ "m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground", eyebrow && "mt-1", )} - sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }} > {title} - + {subtitle} diff --git a/src/domain/work-orders/api/work-order-uplifts-api.ts b/src/domain/work-orders/api/work-order-uplifts-api.ts new file mode 100644 index 00000000..866ff164 --- /dev/null +++ b/src/domain/work-orders/api/work-order-uplifts-api.ts @@ -0,0 +1,61 @@ +import { API_PATHS } from "@/api/api-paths"; +import { apiGet, apiPost } from "@/api/api"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { + mapWorkOrderUplift, + mapWorkOrderUplifts, +} from "@/domain/work-orders/mappers/work-order-uplift-mapper"; +import type { + CreateWorkOrderUpliftInput, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; + +function extractUpliftItems(data: unknown): unknown[] { + if (Array.isArray(data)) { + return data; + } + const record = typeof data === "object" && data != null ? (data as Record) : {}; + const items = record.items ?? record.Items ?? record.uplifts ?? record.Uplifts; + return Array.isArray(items) ? items : []; +} + +export const workOrderUpliftsApi = { + list: async (workOrderId: string | number): Promise => { + const data = await apiGet(API_PATHS.workOrder.uplifts(workOrderId)); + const unwrapped = handleApiResponse(data); + return mapWorkOrderUplifts(extractUpliftItems(unwrapped)); + }, + + create: async ( + workOrderId: string | number, + input: CreateWorkOrderUpliftInput, + ): Promise => { + const data = await apiPost(API_PATHS.workOrder.uplifts(workOrderId), { + amount: input.amount, + notes: input.notes, + }); + return mapWorkOrderUplift(handleApiResponse(data)); + }, + + cancel: async ( + workOrderId: string | number, + upliftId: string | number, + ): Promise => { + const data = await apiPost( + API_PATHS.workOrder.upliftCancel(workOrderId, upliftId), + {}, + ); + return mapWorkOrderUplift(handleApiResponse(data)); + }, + + revoke: async ( + workOrderId: string | number, + upliftId: string | number, + reason?: string, + ): Promise => { + const data = await apiPost(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), { + reason, + }); + return mapWorkOrderUplift(handleApiResponse(data)); + }, +}; diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index a12162c2..944ad710 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -1,4 +1,5 @@ import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper"; +import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper"; import { DOC_STATUS_API, LIFECYCLE_STATUS_API_TO_FE, @@ -272,6 +273,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { rowVersion: readRowVersion(item, "rowVersion", "RowVersion"), dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"), flagColor: readFlagColor(item), + upliftSummary: mapUpliftSummary(item), }; return row; diff --git a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts new file mode 100644 index 00000000..a4e9f32f --- /dev/null +++ b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts @@ -0,0 +1,152 @@ +import type { + UpliftSummary, + WorkOrderUplift, + WorkOrderUpliftStatus, +} from "@/domain/work-orders/types/work-order-uplift"; +import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + return ""; +} + +function readNumber(record: Record, ...keys: string[]): number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number") { + return value; + } + if (typeof value === "string" && value !== "") { + const parsed = Number(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + } + return null; +} + +function readBool(record: Record, ...keys: string[]): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + } + return false; +} + +function readId(record: Record): string | number { + const value = record.id ?? record.Id; + if (typeof value === "string" || typeof value === "number") { + return value; + } + return ""; +} + +function readOptionalId( + record: Record, + ...keys: string[] +): string | number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" || typeof value === "number") { + return value; + } + } + return null; +} + +const STATUS_SET = new Set(WORK_ORDER_UPLIFT_STATUSES); + +function normalizeUpliftStatus(raw: string): WorkOrderUpliftStatus { + const normalized = raw.trim().toLowerCase().replace(/\s+/g, "_"); + if (STATUS_SET.has(normalized)) { + return normalized as WorkOrderUpliftStatus; + } + if (normalized === "canceled") { + return "cancelled"; + } + if (normalized === "autoapproved") { + return "auto_approved"; + } + return "pending"; +} + +export function mapUpliftSummary(raw: unknown): UpliftSummary { + const item = asRecord(raw); + const nested = asRecord(item.upliftSummary ?? item.UpliftSummary); + const source = Object.keys(nested).length > 0 ? nested : item; + + const pendingCount = + readNumber(source, "pendingCount", "PendingCount") ?? + readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ?? + 0; + const hasUplift = + readBool(source, "hasUplift", "HasUplift") || + readBool(item, "hasUplift", "HasUplift") || + pendingCount > 0; + + const primaryStatusRaw = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status"); + const amount = readNumber(source, "amount", "Amount"); + + const summary: UpliftSummary = { + hasUplift, + pendingCount, + }; + + if (primaryStatusRaw) { + summary.primaryStatus = normalizeUpliftStatus(primaryStatusRaw); + } + if (amount != null) { + summary.amount = amount; + } + + return summary; +} + +export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift { + const item = asRecord(raw); + const statusRaw = readString(item, "status", "Status"); + + return { + id: readId(item), + status: statusRaw ? normalizeUpliftStatus(statusRaw) : "pending", + amount: readNumber(item, "amount", "Amount", "requestedNTE", "RequestedNTE") ?? 0, + notes: readString(item, "notes", "Notes", "vendorReason", "VendorReason"), + requestedAt: readString(item, "requestedAt", "RequestedAt"), + requestedById: readOptionalId( + item, + "requestedById", + "RequestedById", + "requesterId", + "RequesterId", + ), + requestedByName: readString( + item, + "requestedByName", + "RequestedByName", + "requestedByVendorName", + "RequestedByVendorName", + ), + decidedAt: readString(item, "decidedAt", "DecidedAt"), + decidedByName: readString(item, "decidedByName", "DecidedByName"), + decisionNote: readString(item, "decisionNote", "DecisionNote"), + }; +} + +export function mapWorkOrderUplifts(rawItems: unknown[]): WorkOrderUplift[] { + return rawItems.map(mapWorkOrderUplift); +} diff --git a/src/domain/work-orders/mocks/work-order-uplifts-mock-store.ts b/src/domain/work-orders/mocks/work-order-uplifts-mock-store.ts new file mode 100644 index 00000000..df3bd854 --- /dev/null +++ b/src/domain/work-orders/mocks/work-order-uplifts-mock-store.ts @@ -0,0 +1,123 @@ +import type { + CreateWorkOrderUpliftInput, + UpliftSummary, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; + +const store = new Map(); +let nextId = 9000; + +function keyFor(workOrderId: string | number): string { + return String(workOrderId); +} + +function computeSummary(items: WorkOrderUplift[]): UpliftSummary { + if (items.length === 0) { + return { hasUplift: false, pendingCount: 0 }; + } + + const pendingCount = items.filter((item) => item.status === "pending").length; + const primary = items[0]; + + return { + hasUplift: true, + pendingCount, + primaryStatus: primary.status, + amount: primary.amount, + }; +} + +function seedIfEmpty(workOrderId: string | number): WorkOrderUplift[] { + const key = keyFor(workOrderId); + const existing = store.get(key); + if (existing) { + return existing; + } + + const numericId = Number(workOrderId); + if (!Number.isNaN(numericId) && numericId % 3 === 0) { + const seeded: WorkOrderUplift[] = [ + { + id: nextId++, + status: "pending", + amount: 450, + notes: "Additional coil cleaning required", + requestedAt: new Date().toISOString(), + requestedById: "mock-vendor", + requestedByName: "Mock Vendor", + decidedAt: "", + decidedByName: "", + decisionNote: "", + }, + ]; + store.set(key, seeded); + return seeded; + } + + store.set(key, []); + return []; +} + +export const workOrderUpliftsMockStore = { + list(workOrderId: string | number): WorkOrderUplift[] { + return [...seedIfEmpty(workOrderId)]; + }, + + create(workOrderId: string | number, input: CreateWorkOrderUpliftInput): WorkOrderUplift { + const items = seedIfEmpty(workOrderId); + const created: WorkOrderUplift = { + id: nextId++, + status: "pending", + amount: input.amount, + notes: input.notes, + requestedAt: new Date().toISOString(), + requestedById: "internal-user", + requestedByName: "Internal User", + decidedAt: "", + decidedByName: "", + decisionNote: "", + }; + items.unshift(created); + store.set(keyFor(workOrderId), items); + return created; + }, + + cancel(workOrderId: string | number, upliftId: string | number): WorkOrderUplift { + const items = seedIfEmpty(workOrderId); + const target = items.find((item) => String(item.id) === String(upliftId)); + if (!target) { + throw new Error("Uplift not found"); + } + if (target.status !== "pending") { + throw new Error("Only pending uplifts can be cancelled"); + } + target.status = "cancelled"; + return target; + }, + + revoke( + workOrderId: string | number, + upliftId: string | number, + reason?: string, + ): WorkOrderUplift { + const items = seedIfEmpty(workOrderId); + const target = items.find((item) => String(item.id) === String(upliftId)); + if (!target) { + throw new Error("Uplift not found"); + } + if (target.status !== "auto_approved" && target.status !== "approved") { + throw new Error("Only approved or auto-approved uplifts can be revoked"); + } + if (target.status === "approved" && !reason?.trim()) { + throw new Error("Revoke reason is required for approved uplifts"); + } + target.status = "revoked"; + target.decisionNote = reason?.trim() ?? ""; + target.decidedAt = new Date().toISOString(); + return target; + }, + + summary(workOrderId: string | number): UpliftSummary { + return computeSummary(seedIfEmpty(workOrderId)); + }, +}; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4bcb4b60..a878c1eb 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -1,4 +1,5 @@ import type { WizardWOStatus, WOType } from "./work-order-wizard"; +import type { UpliftSummary } from "./work-order-uplift"; export type CompletionDocStatus = "Yes" | "No" | "NN" | ""; @@ -49,6 +50,7 @@ export interface WorkOrderTableRow { dispatchRowVersion: string; /** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */ flagColor: string | null; + upliftSummary: UpliftSummary; } export interface WorkOrderTableDraft { diff --git a/src/domain/work-orders/types/work-order-uplift.ts b/src/domain/work-orders/types/work-order-uplift.ts new file mode 100644 index 00000000..aaed9c16 --- /dev/null +++ b/src/domain/work-orders/types/work-order-uplift.ts @@ -0,0 +1,41 @@ +export const WORK_ORDER_UPLIFT_STATUSES = [ + "pending", + "approved", + "auto_approved", + "rejected", + "cancelled", + "revoked", +] as const; + +export type WorkOrderUpliftStatus = (typeof WORK_ORDER_UPLIFT_STATUSES)[number]; + +export interface UpliftSummary { + hasUplift: boolean; + pendingCount: number; + primaryStatus?: WorkOrderUpliftStatus; + amount?: number; +} + +export interface WorkOrderUplift { + id: string | number; + status: WorkOrderUpliftStatus; + amount: number; + notes: string; + requestedAt: string; + requestedById: string | number | null; + requestedByName: string; + decidedAt: string; + decidedByName: string; + decisionNote: string; +} + +export interface CreateWorkOrderUpliftInput { + amount: number; + notes: string; + attachments?: File[]; +} + +export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = { + hasUplift: false, + pendingCount: 0, +}; diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts new file mode 100644 index 00000000..ec9cd3cc --- /dev/null +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -0,0 +1,87 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; +import type { + CreateWorkOrderUpliftInput, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { requireQueryParam } from "@/lib/query/require-query-param"; + +function invalidateUpliftQueries( + queryClient: ReturnType, + workOrderId: string | number, +): void { + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.uplifts(workOrderId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); +} + +export function useWorkOrderUplifts( + workOrderId: string | number | null | undefined, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""), + queryFn: () => + workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")), + enabled: workOrderId != null && workOrderId !== "", + }); +} + +export function useCreateWorkOrderUplift( + workOrderId: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateWorkOrderUpliftInput) => + workOrderUpliftsApi.create(workOrderId, input), + onSuccess: () => { + invalidateUpliftQueries(queryClient, workOrderId); + toast.success("Uplift request created"); + }, + onError: (error) => { + toast.error(error.message || "Failed to create uplift"); + }, + }); +} + +export function useCancelWorkOrderUplift( + workOrderId: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (upliftId: string | number) => workOrderUpliftsApi.cancel(workOrderId, upliftId), + onSuccess: () => { + invalidateUpliftQueries(queryClient, workOrderId); + toast.success("Uplift cancelled"); + }, + onError: (error) => { + toast.error(error.message || "Failed to cancel uplift"); + }, + }); +} + +export function useRevokeWorkOrderUplift( + workOrderId: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ upliftId, reason }: { upliftId: string | number; reason?: string }) => + workOrderUpliftsApi.revoke(workOrderId, upliftId, reason), + onSuccess: () => { + invalidateUpliftQueries(queryClient, workOrderId); + toast.success("Uplift revoked"); + }, + onError: (error) => { + toast.error(error.message || "Failed to revoke uplift"); + }, + }); +} diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts new file mode 100644 index 00000000..431f74c1 --- /dev/null +++ b/src/domain/work-orders/utils/uplift-display-utils.ts @@ -0,0 +1,103 @@ +import type { + WorkOrderUplift, + UpliftSummary, + WorkOrderUpliftStatus, +} from "@/domain/work-orders/types/work-order-uplift"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +export function formatUpliftMoney(value: number | null | undefined): string { + if (value == null || Number.isNaN(value)) { + return "—"; + } + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(value); +} + +const STATUS_LABELS: Record = { + pending: "Pending", + approved: "Approved", + auto_approved: "Auto", + rejected: "Rejected", + cancelled: "Cancelled", + revoked: "Revoked", +}; + +export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string { + return STATUS_LABELS[status]; +} + +export function getUpliftCellLabel(summary: UpliftSummary): string | null { + if (summary.pendingCount > 0) { + return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`; + } + if (summary.primaryStatus) { + const label = formatUpliftStatusLabel(summary.primaryStatus); + if (summary.amount != null) { + return `${label} ${formatUpliftMoney(summary.amount)}`; + } + return label; + } + if (summary.hasUplift) { + return "Uplift"; + } + return null; +} + +export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): { + background: string; + color: string; +} { + switch (status) { + case "pending": + return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" }; + case "approved": + return { background: "var(--status-success-bg, #DCFCE7)", color: "var(--color-success)" }; + case "auto_approved": + return { background: "var(--color-primary-tint)", color: "var(--color-primary)" }; + case "rejected": + return { background: "#FEE2E2", color: "#B91C1C" }; + case "cancelled": + case "revoked": + return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" }; + default: + return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" }; + } +} + +export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): boolean { + return status === "Completed" || status === "Canceled"; +} + +export function canOpenUpliftsDialog( + summary: UpliftSummary, + status: WorkOrderTableRow["status"], +): boolean { + if (summary.hasUplift || summary.pendingCount > 0) { + return true; + } + return !isWorkOrderUpliftsReadOnly(status); +} + +export function canRevokeWorkOrderUplift( + uplift: Pick, + currentUserId: string | number | null | undefined, + isAdmin: boolean, +): boolean { + if (uplift.status === "auto_approved") { + // SH-196: auto-approved revoke is dispatcher-own only (no admin bypass). + return ( + currentUserId != null && + currentUserId !== "" && + uplift.requestedById != null && + String(uplift.requestedById) === String(currentUserId) + ); + } + return isAdmin && uplift.status === "approved"; +} + +export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean { + return status === "approved"; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index c136993d..3a1423a3 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -25,6 +25,8 @@ export const queryKeys = { dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const, taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const, dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const, + uplifts: (workOrderId: string | number) => + [...queryKeys.workOrders.all, "uplifts", workOrderId] as const, }, pmSchedules: { all: ["pmSchedules"] as const, diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts index 98716ee7..55ca7411 100644 --- a/src/lib/auth/user-utils.ts +++ b/src/lib/auth/user-utils.ts @@ -4,3 +4,13 @@ export function getPrimaryUserRole( ): string { return userRoles?.split(",")[0]?.trim() || fallback; } + +export function isAdminUser(userRoles: string | null | undefined): boolean { + if (!userRoles) { + return false; + } + return userRoles + .split(",") + .map((role) => role.trim().toLowerCase()) + .includes("admin"); +} diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index 2e9f8f1f..e083f352 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -5,7 +5,6 @@ import { useController } from "react-hook-form"; import { beforeEach, describe, expect, it, vi } from "vitest"; const rosterGet = vi.fn(); -const saveMutate = vi.fn(); vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) }, @@ -29,10 +28,7 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => const actual = await vi.importActual< typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster") >("@/domain/vendors/use-cases/use-save-vendor-company-roster"); - return { - ...actual, - useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }), - }; + return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) }; }); import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form"; @@ -105,7 +101,6 @@ function deferRoster(): { describe("useVendorRosterForm load-error handling", () => { beforeEach(() => { rosterGet.mockReset(); - saveMutate.mockReset(); }); it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => { @@ -201,44 +196,6 @@ describe("useVendorRosterForm load-error handling", () => { }); }); -describe("useVendorRosterForm prototype defaults", () => { - it("does not persist the prototype's untouched optional technician card", () => { - const { result } = renderHook( - () => useVendorRosterForm({ mode: "create", startWithTechnician: true }), - { wrapper: makeWrapper(createClient()) }, - ); - - act(() => { - result.current.submit({ - name: "Independent Vendor LLC", - companyPhone: "(314) 555-0199", - email: "", - address: "", - city: "", - state: "", - zip: "", - googleMapsUrl: "", - notes: "", - technicians: [ - { - contactName: "", - phone: "", - email: "", - preferredContact: "Phone", - tradeSpecialties: "", - isActive: true, - }, - ], - }); - }); - - expect(saveMutate).toHaveBeenCalledWith( - expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }), - expect.any(Object), - ); - }); -}); - describe("useVendorRosterForm stale-selection handling", () => { beforeEach(() => { rosterGet.mockReset(); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index df329a4a..a55ddaf0 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -49,11 +49,11 @@ function rosterWith(technicians: Array>) { }; } -describe("VendorDetailDrawer selected-technician display", () => { +describe("VendorDetailDrawer preferred-contact display", () => { it("renders no preference label when preferredContact is absent", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ - { id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, + { id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, ]), ); @@ -62,15 +62,15 @@ describe("VendorDetailDrawer selected-technician display", () => { withAuth: false, }); - expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); + expect(screen.getByText("Adam")).toBeInTheDocument(); expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); }); - it("keeps the retired preferred-contact value out of the detail view", () => { + it("renders the preference label when an explicit preferredContact is present", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ { - id: 1, + id: 7, contactName: "Adam", phone: "314-555-0198", preferredContact: "Email", @@ -85,52 +85,6 @@ describe("VendorDetailDrawer selected-technician display", () => { withAuth: false, }); - expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); - expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); - }); - - it("falls back to the clicked row when the roster has no matching stable id", () => { - useVendorCompanyRoster.mockReturnValue( - rosterWith([ - { - id: 7, - contactName: "Adam Whyte", - phone: "999-999-9999", - isActive: true, - totalJobs: 99, - }, - ]), - ); - - renderWithProviders(, { - route: "/vendors", - withAuth: false, - }); - - expect(screen.getByText("0")).toBeInTheDocument(); - expect(screen.queryByText("999-999-9999")).not.toBeInTheDocument(); - }); - - it("does not expose a status control for a different technician", () => { - useVendorCompanyRoster.mockReturnValue( - rosterWith([ - { - id: 7, - contactName: "Different Technician", - phone: "314-555-0101", - email: "", - tradeSpecialties: "Plumbing", - isActive: true, - totalJobs: 10, - }, - ]), - ); - - renderWithProviders( - , - { route: "/vendors", withAuth: false }, - ); - - expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument(); + expect(screen.getByText("Prefers Email")).toBeInTheDocument(); }); }); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index a5fd7a64..64c71c69 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -1,14 +1,21 @@ -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import VendorsListPage from "@/app/(protected)/vendors/index"; -import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; const useVendorsList = vi.fn(); const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); +const navigate = vi.fn(); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { ...actual, useNavigate: () => navigate }; +}); + vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ useVendorsList: (...args: unknown[]) => useVendorsList(...args), })); @@ -54,32 +61,6 @@ const inactiveVendor = { isActive: false, }; -const activeRoster: VendorCompanyRoster = { - companyId: "co-1", - rowVersion: "rv-1", - name: "Gateway Plumbing", - companyPhone: "(314) 555-0100", - email: "dispatch@gatewayplumbing.com", - address: "", - city: "St. Louis", - state: "MO", - zip: "", - googleMapsUrl: "", - notes: "", - technicians: [ - { - id: 1, - contactName: "Adam Whyte", - phone: "(314) 555-0198", - email: "dispatch@gatewayplumbing.com", - preferredContact: "Email", - tradeSpecialties: "Backflow Preventers", - isActive: true, - totalJobs: 0, - }, - ], -}; - function result(items: VendorListItem[], totalCount: number) { return { data: { items, totalCount, page: 1, pageSize: 12 }, @@ -90,13 +71,7 @@ function result(items: VendorListItem[], totalCount: number) { } function setupDefaults() { - useVendorCompanyRoster.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); + useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null }); useVendorDeactivationImpact.mockReturnValue({ data: undefined, isLoading: false, @@ -105,7 +80,7 @@ function setupDefaults() { } describe("VendorsListPage", () => { - it("renders prototype-aligned status and two row actions in both tabs", async () => { + it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => { setupDefaults(); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); @@ -116,20 +91,11 @@ describe("VendorsListPage", () => { expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); - expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); - expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "View vendor Gateway Plumbing" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }), - ).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument(); await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" })); expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument(); - expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); }); @@ -148,18 +114,39 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); - expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); + expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument(); + expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument(); }); it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { setupDefaults(); useVendorCompanyRoster.mockReturnValue({ - data: activeRoster, + data: { + companyId: "co-1", + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + address: "", + city: "St. Louis", + state: "MO", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + id: 1, + contactName: "Adam Whyte", + phone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + preferredContact: "Email", + tradeSpecialties: "Backflow Preventers", + isActive: true, + }, + ], + }, isLoading: false, - isError: false, error: null, - refetch: vi.fn(), }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); @@ -174,18 +161,11 @@ describe("VendorsListPage", () => { row.focus(); await userEvent.keyboard("{Enter}"); - expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument(); }); it("blocks deactivation when the preflight reports open work orders", async () => { setupDefaults(); - useVendorCompanyRoster.mockReturnValue({ - data: activeRoster, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); useVendorDeactivationImpact.mockReturnValue({ data: { vendorId: 1, @@ -209,8 +189,7 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - await userEvent.click(screen.getByRole("switch", { name: "Active status" })); + await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" })); expect( screen.getByText(/cannot be deactivated because it still has open work orders/), @@ -220,51 +199,9 @@ describe("VendorsListPage", () => { expect(mutate).not.toHaveBeenCalled(); }); - it("preserves inline edits when deactivation is cancelled", async () => { + it("navigates to the vendor edit route when the table Edit control is clicked", async () => { setupDefaults(); - useVendorCompanyRoster.mockReturnValue({ - data: activeRoster, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); - useVendorDeactivationImpact.mockReturnValue({ - data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] }, - isLoading: false, - error: null, - }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); - - renderWithProviders(, { route: "/vendors", withAuth: false }); - - await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - const company = screen.getByRole("textbox", { name: "Company" }); - await userEvent.clear(company); - await userEvent.type(company, "Draft Company Name"); - await userEvent.click(screen.getByRole("switch", { name: "Active status" })); - await userEvent.click( - within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", { - name: "Cancel", - }), - ); - - expect(company).toBeInTheDocument(); - expect(company).toHaveValue("Draft Company Name"); - }); - - it("opens inline edit when the table Edit control is clicked", async () => { - setupDefaults(); - useVendorCompanyRoster.mockReturnValue({ - data: activeRoster, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); + navigate.mockClear(); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([activeVendor], 1) : result([], 0); @@ -274,20 +211,16 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); + expect(navigate).toHaveBeenCalledWith("/vendors/edit/1"); + expect( + screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }), + ).not.toBeInTheDocument(); }); - it("opens inline company edit when the row has no vendor id", async () => { + it("navigates to the company route when editing a vendor without a vendor id", async () => { setupDefaults(); + navigate.mockClear(); const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" }; - useVendorCompanyRoster.mockReturnValue({ - data: { ...activeRoster, companyId: "co-9", technicians: [] }, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); @@ -297,7 +230,6 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); + expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9"); }); }); diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx new file mode 100644 index 00000000..2b3abef7 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell"; +import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +const baseRow: WorkOrderTableRow = { + id: 42, + woNumber: "WO-42", + site: "CHI-1042", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-20", + targetWeek: "2026-07-20", + dayGroup: "2026-07-20", + dayLabel: "Mon", + location: "Chicago Loop", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "1", + company: "Vendor Co", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + upliftSummary: { hasUplift: true, pendingCount: 1, primaryStatus: "pending", amount: 450 }, +}; + +vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({ + useWorkOrderUplifts: () => ({ + data: [ + { + id: 1, + status: "pending", + amount: 450, + notes: "Extra work", + requestedAt: "2026-07-20T10:00:00Z", + requestedById: "vendor-1", + requestedByName: "Vendor", + decidedAt: "", + decidedByName: "", + decisionNote: "", + }, + ], + isLoading: false, + error: null, + refetch: vi.fn(), + }), + useCreateWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), +})); + +describe("UpliftCell affordances", () => { + it("renders pending pill and opens handler when onOpen is provided", () => { + const onOpen = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /1 pending/i })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("shows read-only dash when onOpen is omitted", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); + +describe("WorkOrderUpliftsDialog affordances", () => { + it("shows create form for editable work orders", () => { + renderWithProviders(, { + withAuth: true, + }); + + expect(screen.getByRole("heading", { name: /uplifts — wo wo-42/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel pending/i })).toBeInTheDocument(); + }); + + it("hides create and cancel actions when work order is completed", () => { + renderWithProviders( + , + { withAuth: true }, + ); + + expect(screen.getByText(/uplifts are read-only/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /cancel pending/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index 004e96d3..4c64e8c4 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -109,6 +109,7 @@ const canceledRow: WorkOrderTableRow = { rowVersion: "", dispatchRowVersion: "", flagColor: null, + upliftSummary: { hasUplift: false, pendingCount: 0 }, }; vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ diff --git a/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts new file mode 100644 index 00000000..599ae34e --- /dev/null +++ b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), +})); + +import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; + +describe("workOrderUpliftsApi", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + }); + + it("propagates a real API list failure instead of returning mock uplifts", async () => { + const error = new Error("Unauthorized"); + apiGet.mockRejectedValueOnce(error); + + await expect(workOrderUpliftsApi.list(42)).rejects.toBe(error); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts new file mode 100644 index 00000000..9eb56502 --- /dev/null +++ b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + mapUpliftSummary, + mapWorkOrderUplift, +} from "@/domain/work-orders/mappers/work-order-uplift-mapper"; +import { + getUpliftCellLabel, + getUpliftStatusPillStyle, +} from "@/domain/work-orders/utils/uplift-display-utils"; + +describe("mapUpliftSummary", () => { + it("maps nested PascalCase upliftSummary from board row payload", () => { + const result = mapUpliftSummary({ + id: 10, + UpliftSummary: { + HasUplift: true, + PendingCount: 2, + PrimaryStatus: "Pending", + Amount: 500, + }, + }); + + expect(result).toEqual({ + hasUplift: true, + pendingCount: 2, + primaryStatus: "pending", + amount: 500, + }); + }); + + it("derives hasUplift from pendingUpliftCount when nested summary is absent", () => { + const result = mapUpliftSummary({ + pendingUpliftCount: 1, + }); + + expect(result.hasUplift).toBe(true); + expect(result.pendingCount).toBe(1); + }); + + it("normalizes auto-approved and canceled status aliases", () => { + expect(mapUpliftSummary({ primaryStatus: "AutoApproved" }).primaryStatus).toBe("auto_approved"); + expect(mapWorkOrderUplift({ status: "Canceled" }).status).toBe("cancelled"); + }); + + it("maps requester identity for revoke ownership checks", () => { + expect(mapWorkOrderUplift({ requestedById: "dispatcher-1" }).requestedById).toBe( + "dispatcher-1", + ); + }); +}); + +describe("uplift display utils", () => { + it("builds pending pill label and style", () => { + const summary = { hasUplift: true, pendingCount: 1, primaryStatus: "pending" as const }; + expect(getUpliftCellLabel(summary)).toBe("1 Pending"); + expect(getUpliftStatusPillStyle("pending")).toEqual({ + background: "var(--status-pending-bg)", + color: "var(--status-pending-text)", + }); + }); + + it("includes amount in non-pending primary status label", () => { + expect( + getUpliftCellLabel({ + hasUplift: true, + pendingCount: 0, + primaryStatus: "approved", + amount: 1200, + }), + ).toBe("Approved $1,200"); + }); +}); diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index 25788b12..257a8503 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -4,6 +4,7 @@ import { isSlideOverDraftDirty, SLIDE_OVER_EDIT_KEYS, } from "@/domain/work-orders/utils/slide-over-draft"; +import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; function baseRow(overrides: Partial = {}): WorkOrderTableRow { @@ -49,6 +50,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow rowVersion: "1", dispatchRowVersion: "", flagColor: null, + upliftSummary: EMPTY_UPLIFT_SUMMARY, ...overrides, }; } diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 4667ee78..3fa7af62 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; import { @@ -53,6 +54,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow rowVersion: "AQAAAAAAAAA=", dispatchRowVersion: "", flagColor: null, + upliftSummary: EMPTY_UPLIFT_SUMMARY, ...overrides, }; } diff --git a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts new file mode 100644 index 00000000..c441ec4f --- /dev/null +++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + canRevokeWorkOrderUplift, + upliftRevokeRequiresReason, +} from "@/domain/work-orders/utils/uplift-display-utils"; + +describe("canRevokeWorkOrderUplift", () => { + it("allows a dispatcher to revoke their own auto-approved uplift", () => { + expect( + canRevokeWorkOrderUplift( + { status: "auto_approved", requestedById: "dispatcher-1" }, + "dispatcher-1", + false, + ), + ).toBe(true); + }); + + it("rejects a dispatcher revoking another user's auto-approved uplift", () => { + expect( + canRevokeWorkOrderUplift( + { status: "auto_approved", requestedById: "other-dispatcher" }, + "dispatcher-1", + false, + ), + ).toBe(false); + }); + + it("rejects admin revoking auto-approved uplift they did not request", () => { + expect( + canRevokeWorkOrderUplift( + { status: "auto_approved", requestedById: "dispatcher-1" }, + "admin-user", + true, + ), + ).toBe(false); + }); + + it("allows admin to revoke their own auto-approved uplift as the requester", () => { + expect( + canRevokeWorkOrderUplift( + { status: "auto_approved", requestedById: "admin-user" }, + "admin-user", + true, + ), + ).toBe(true); + }); + + it("allows approved revoke only for admin", () => { + const uplift = { status: "approved" as const, requestedById: "dispatcher-1" }; + expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", false)).toBe(false); + expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", true)).toBe(true); + }); + + it("rejects revoke for other statuses", () => { + expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1", true)).toBe( + false, + ); + expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1", true)).toBe( + false, + ); + }); +}); + +describe("upliftRevokeRequiresReason", () => { + it("requires reason only for approved uplifts", () => { + expect(upliftRevokeRequiresReason("approved")).toBe(true); + expect(upliftRevokeRequiresReason("auto_approved")).toBe(false); + }); +}); diff --git a/src/test/lib/auth/user-utils.test.ts b/src/test/lib/auth/user-utils.test.ts new file mode 100644 index 00000000..eddb88c5 --- /dev/null +++ b/src/test/lib/auth/user-utils.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isAdminUser } from "@/lib/auth/user-utils"; + +describe("isAdminUser", () => { + it("returns true when Admin appears in roles", () => { + expect(isAdminUser("Admin")).toBe(true); + expect(isAdminUser("Dispatcher,Admin")).toBe(true); + expect(isAdminUser(" admin ")).toBe(true); + }); + + it("returns false for non-admin roles", () => { + expect(isAdminUser("Dispatcher")).toBe(false); + expect(isAdminUser(undefined)).toBe(false); + }); +}); diff --git a/tmp/pr-descriptions/pr3-uplifts-column-tab.md b/tmp/pr-descriptions/pr3-uplifts-column-tab.md new file mode 100644 index 00000000..d0fa7a36 --- /dev/null +++ b/tmp/pr-descriptions/pr3-uplifts-column-tab.md @@ -0,0 +1,46 @@ +## Jira + +- [SH-196](https://luby-us.atlassian.net/browse/SH-196) + +## QA residual + +- Conscious adaptation: **dialog** vs design slide-over tab (no slide-over on this `dev`). +- P0 done: admin revoke of `approved` with required reason. +- P1: `+ Add` / Manage + attachments polish. + +## Summary + +Adds the **UPLIFT** column to the work orders board table (after COMP DOC) with a status pill that opens a **uplifts dialog** (not a slide-over tab — board currently navigates to `/workorders/:id` on row detail). + +- Maps `upliftSummary` on each board row: `{ hasUplift, pendingCount, primaryStatus?, amount? }` +- Dialog: list uplifts, create (amount/notes + attachments UI stub), cancel pending, revoke `auto_approved` (optional reason) and `approved` for **admin** (required reason, SH-196); read-only when status is Completed/Canceled +- WO-scoped API client + query keys under `workorders/{id}/uplifts` +- DEV in-memory mock fallback when API calls fail + +## API / assumptions + +| Item | Assumption | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| UI surface | **Dialog** from UPLIFT column click — design ref uses slide-over tab, but `dev` has no slide-over; detail opens via route navigation | +| Row field | `upliftSummary` on GET `/board` and `/board/search` rows (see `docs/work-orders/pending-be-contract.md`) | +| Endpoints | `GET/POST workorders/{id}/uplifts`, `POST …/uplifts/{id}/cancel\|revoke` — distinct from NTE queue `uplifts` API | +| Attachments | Create form shows disabled attachments field until BE accepts files | +| DEV mock | When `import.meta.env.DEV` and API throws, `work-order-uplifts-mock-store` serves list/create/cancel/revoke | + +## Out of scope (later PRs) + +- Closability / Comp Doc pending gate (PR4) +- Advanced filter uplift facets (PR5) +- Vendor Portal NTE queue + +## Test plan + +- [ ] Board row shows UPLIFT pill after COMP DOC (pending count or primary status + amount) +- [ ] Click UPLIFT opens dialog; Completed/Canceled rows are read-only +- [ ] Create uplift (DEV mock) updates list; cancel pending / revoke auto_approved work +- [ ] Admin can revoke `approved` uplifts with required reason; non-admin cannot +- [ ] `npm run verify` green + +## Design ref + +`seahaven-desing` — `WorkOrders.tsx` `UpliftCell` (~L4669)
    — + — + e.stopPropagation()}> + handlers.onOpenUplifts!(row) + : undefined + } + /> +