mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
chore(work-orders): sync feature/wo-board-api with current dev
This commit is contained in:
commit
e5300da9fb
18 changed files with 1268 additions and 769 deletions
59
e2e/vendors/vendor-operations.spec.ts
vendored
59
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -16,9 +16,68 @@ async function fulfillJson(route: Route, body: unknown, status = 200) {
|
|||
|
||||
async function authenticate(page: Page) {
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
await page.route("**/api/Dashboard/Stats", (route) =>
|
||||
fulfillJson(route, {
|
||||
data: {
|
||||
openWorkOrders: 0,
|
||||
pendingApprovals: 0,
|
||||
scheduledPMs: 0,
|
||||
activeAssets: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
|
||||
}
|
||||
|
||||
test.describe("Vendor operations", () => {
|
||||
test("keeps the most specific vendor navigation item exclusively current", async ({ page }) => {
|
||||
await authenticate(page);
|
||||
await page.route("**/api/vendor-operations/insights*", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
|
||||
await page.goto("/vendors/insights");
|
||||
|
||||
const vendorLinks = page.locator('a[href^="/vendors"]');
|
||||
await expect(
|
||||
vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }),
|
||||
).toHaveAttribute("aria-current", "page");
|
||||
await expect(
|
||||
vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }),
|
||||
).not.toHaveAttribute("aria-current", "page");
|
||||
await expect(
|
||||
vendorLinks.filter({ has: page.getByText("Site Preferences", { exact: true }) }),
|
||||
).not.toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 700, height: 900 });
|
||||
await authenticate(page);
|
||||
await page.route("**/api/dispatches*", (route) => fulfillJson(route, { items: [], total: 0 }));
|
||||
await page.route("**/api/Vendor/GetVendorList*", (route) =>
|
||||
fulfillJson(route, { Data: [], TotalCount: 0 }),
|
||||
);
|
||||
|
||||
await page.goto("/vendor-pos");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Vendor POs" })).toBeVisible();
|
||||
await expect(page.getByText("0 total", { exact: true })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Search PO #, dispatch, vendor, WO...")).toBeVisible();
|
||||
await expect(page.locator("label").filter({ hasText: /^Status$/ })).toBeVisible();
|
||||
await expect(page.locator("label").filter({ hasText: /^Vendor$/ })).toBeVisible();
|
||||
await expect(page.getByLabel("From", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("To", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.getByPlaceholder("Search PO #, dispatch, vendor, WO...")
|
||||
.evaluate((element) => element.clientWidth),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(280);
|
||||
});
|
||||
|
||||
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
|
|||
48
e2e/vendors/vendors.spec.ts
vendored
48
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -80,9 +80,19 @@ async function captureVisual(page: Page, path: string) {
|
|||
|
||||
async function mockVendorApi(
|
||||
page: Page,
|
||||
options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {},
|
||||
options: {
|
||||
listError?: boolean;
|
||||
deactivationBlocked?: boolean;
|
||||
deleteConflict?: boolean;
|
||||
legacyGateway?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const state: MockState = { listUrls: [] };
|
||||
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
|
||||
if (options.legacyGateway) {
|
||||
vendorRecords[0].Phone = "555-1234 ext 9";
|
||||
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
|
||||
}
|
||||
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
|
|
@ -131,7 +141,7 @@ async function mockVendorApi(
|
|||
}
|
||||
|
||||
const isActive = url.searchParams.get("isActive") !== "false";
|
||||
const source = isActive ? activeVendors : inactiveVendors;
|
||||
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
|
||||
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
||||
const company = url.searchParams.get("companies[0]");
|
||||
const filtered = source.filter(
|
||||
|
|
@ -175,8 +185,12 @@ async function mockVendorApi(
|
|||
const id = route.request().url().split("/").pop() ?? "";
|
||||
if (route.request().method() === "PUT") {
|
||||
state.updatedBody = route.request().postDataJSON();
|
||||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||
if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") {
|
||||
vendor.IsActive = state.updatedBody.isActive;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) },
|
||||
data: { ...vendor, ...state.updatedBody, Id: Number(id) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -190,10 +204,12 @@ async function mockVendorApi(
|
|||
return;
|
||||
}
|
||||
state.deletedId = id;
|
||||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||
if (options.legacyGateway && vendor) vendor.IsActive = false;
|
||||
await fulfillJson(route, { status: "200" });
|
||||
return;
|
||||
}
|
||||
const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id);
|
||||
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||
await fulfillJson(route, { data: vendor });
|
||||
});
|
||||
await page.route(/\/api\/vendors\/?$/, async (route) => {
|
||||
|
|
@ -336,6 +352,30 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
expect(state.updatedBody?.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
|
||||
const state = await mockVendorApi(page, { legacyGateway: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||
await expect.poll(() => state.deletedId).toBe("1");
|
||||
|
||||
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||
await expect(detailDrawer.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
|
||||
await detailDrawer.getByRole("switch", { name: "Inactive" }).click();
|
||||
await detailDrawer.getByRole("button", { name: "Save" }).click();
|
||||
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
||||
await expect(detailDrawer.getByText("Active").first()).toBeVisible();
|
||||
|
||||
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
|
||||
await page.getByRole("tab", { name: "Active (2)" }).click();
|
||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
|
||||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
import type { VendorPosFilters } from "../_hooks/use-vendor-pos-filters";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
"",
|
||||
"Sent",
|
||||
"Acknowledged",
|
||||
"In Progress",
|
||||
"Completed",
|
||||
"Verified",
|
||||
"Cancelled",
|
||||
];
|
||||
|
||||
interface VendorPosListControlsProps {
|
||||
filters: VendorPosFilters;
|
||||
vendors: VendorListItem[];
|
||||
totalCount: number;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFilterChange: (name: Exclude<keyof VendorPosFilters, "search">, value: string) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
export function VendorPosListControls({
|
||||
filters,
|
||||
vendors,
|
||||
totalCount,
|
||||
isFetching,
|
||||
isLoading,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
onClearFilters,
|
||||
}: VendorPosListControlsProps) {
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
flexShrink: 0,
|
||||
flexWrap: "wrap",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1" sx={{ flexShrink: 0, whiteSpace: "nowrap" }}>
|
||||
Vendor POs
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: "text.secondary", flexShrink: 0, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
value={filters.search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search PO #, dispatch, vendor, WO..."
|
||||
sx={{ width: { xs: "100%", sm: 360 }, minWidth: 280, flexShrink: 0 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{ flexWrap: "wrap", alignItems: "flex-start" }}
|
||||
>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{ width: { xs: "100%", sm: 160 }, minWidth: 140, flexShrink: 0 }}
|
||||
>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
label="Status"
|
||||
value={filters.status}
|
||||
onChange={(event) => onFilterChange("status", event.target.value)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status || "all"} value={status}>
|
||||
{status || "All"}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{ width: { xs: "100%", sm: 240 }, minWidth: 180, flexShrink: 0 }}
|
||||
>
|
||||
<InputLabel>Vendor</InputLabel>
|
||||
<Select
|
||||
label="Vendor"
|
||||
value={filters.vendorId}
|
||||
onChange={(event) => onFilterChange("vendorId", event.target.value)}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{vendors.map((vendor) => (
|
||||
<MenuItem key={String(vendor.id)} value={String(vendor.id)}>
|
||||
{vendor.companyName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label="From"
|
||||
value={filters.dateFrom}
|
||||
onChange={(event) => onFilterChange("dateFrom", event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label="To"
|
||||
value={filters.dateTo}
|
||||
onChange={(event) => onFilterChange("dateTo", event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={onClearFilters}
|
||||
sx={{ minWidth: "max-content", flexShrink: 0 }}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import {
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { VendorPoListItem } from "@/domain/vendor-pos/types/vendor-po";
|
||||
import { toDateInputValue } from "@/lib/time-utils";
|
||||
|
||||
interface VendorPosListTableProps {
|
||||
rows: VendorPoListItem[];
|
||||
isLoading: boolean;
|
||||
onNavigate: (id: string | number) => void;
|
||||
}
|
||||
|
||||
function formatMoney(value: number | null): string {
|
||||
if (value == null) return "—";
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return "—";
|
||||
return toDateInputValue(value) || "—";
|
||||
}
|
||||
|
||||
export function VendorPosListTable({ rows, isLoading, onNavigate }: VendorPosListTableProps) {
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">PO #</TableCell>
|
||||
<TableCell className="font-semibold">Dispatch #</TableCell>
|
||||
<TableCell className="font-semibold">Vendor</TableCell>
|
||||
<TableCell className="font-semibold">WO #</TableCell>
|
||||
<TableCell className="font-semibold">Location</TableCell>
|
||||
<TableCell className="font-semibold">NTE</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell className="font-semibold">Uplifts</TableCell>
|
||||
<TableCell className="font-semibold">Dispatched</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<Typography sx={{ color: "text.secondary" }}>No vendor POs found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
key={String(row.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => onNavigate(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onNavigate(row.id);
|
||||
}}
|
||||
>
|
||||
{row.poNumber || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.dispatchNumber || "—"}</TableCell>
|
||||
<TableCell>{row.vendorName || "—"}</TableCell>
|
||||
<TableCell>{row.primaryWOInternalNumber || "—"}</TableCell>
|
||||
<TableCell>{row.locationName || "—"}</TableCell>
|
||||
<TableCell>{formatMoney(row.nteAmount)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={row.status || "—"} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.pendingUpliftCount > 0 ? (
|
||||
<Chip
|
||||
size="small"
|
||||
color="warning"
|
||||
label={`${row.pendingUpliftCount} pending`}
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(row.dispatchedAt)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import type { VendorPoListParams } from "@/domain/vendor-pos/types/vendor-po";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
export interface VendorPosFilters {
|
||||
search: string;
|
||||
status: string;
|
||||
vendorId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}
|
||||
|
||||
type VendorPosFilterName = Exclude<keyof VendorPosFilters, "search">;
|
||||
|
||||
const INITIAL_FILTERS: VendorPosFilters = {
|
||||
search: "",
|
||||
status: "",
|
||||
vendorId: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
};
|
||||
|
||||
export function useVendorPosFilters() {
|
||||
const [filters, setFilters] = useState<VendorPosFilters>(INITIAL_FILTERS);
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const debouncedSearch = useDebounce(filters.search, 300);
|
||||
|
||||
const listParams = useMemo<VendorPoListParams>(
|
||||
() => ({
|
||||
search: debouncedSearch,
|
||||
page: page + 1,
|
||||
pageSize,
|
||||
status: filters.status,
|
||||
vendorId: filters.vendorId,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
}),
|
||||
[
|
||||
debouncedSearch,
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.status,
|
||||
filters.vendorId,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
);
|
||||
|
||||
const setSearch = (search: string) => {
|
||||
setFilters((current) => ({ ...current, search }));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const setFilter = (name: VendorPosFilterName, value: string) => {
|
||||
setFilters((current) => ({ ...current, [name]: value }));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
status: "",
|
||||
vendorId: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
}));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const changePageSize = (nextPageSize: number) => {
|
||||
setPageSize(nextPageSize);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
return {
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
listParams,
|
||||
setSearch,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
setPage,
|
||||
changePageSize,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,300 +1,59 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Alert, Box, TablePagination } from "@mui/material";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { VendorPosListControls } from "./_components/vendor-pos-list-controls";
|
||||
import { VendorPosListTable } from "./_components/vendor-pos-list-table";
|
||||
import { useVendorPosFilters } from "./_hooks/use-vendor-pos-filters";
|
||||
import { useVendorPosList } from "@/domain/vendor-pos/use-cases/use-vendor-pos-list";
|
||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { toDateInputValue } from "@/lib/time-utils";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
"",
|
||||
"Sent",
|
||||
"Acknowledged",
|
||||
"In Progress",
|
||||
"Completed",
|
||||
"Verified",
|
||||
"Cancelled",
|
||||
];
|
||||
|
||||
function formatMoney(value: number | null): string {
|
||||
if (value == null) return "—";
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return "—";
|
||||
return toDateInputValue(value) || "—";
|
||||
}
|
||||
|
||||
export default function VendorPosListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [status, setStatus] = useState("");
|
||||
const [vendorId, setVendorId] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
search: debouncedSearch,
|
||||
page: page + 1,
|
||||
pageSize,
|
||||
status,
|
||||
vendorId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
}),
|
||||
[debouncedSearch, page, pageSize, status, vendorId, dateFrom, dateTo],
|
||||
);
|
||||
|
||||
const {
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
listParams,
|
||||
setSearch,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
setPage,
|
||||
changePageSize,
|
||||
} = useVendorPosFilters();
|
||||
const { data, isLoading, isFetching, error } = useVendorPosList(listParams);
|
||||
const { data: vendorsData } = useVendorsList({ page: 1, pageSize: 500 });
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
const vendors = vendorsData?.items ?? [];
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatus("");
|
||||
setVendorId("");
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h1">
|
||||
Vendor POs
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search PO #, dispatch, vendor, WO..."
|
||||
className="min-w-[280px]"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<FormControl size="small" className="min-w-[140px]">
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s || "all"} value={s}>
|
||||
{s || "All"}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" className="min-w-[180px]">
|
||||
<InputLabel>Vendor</InputLabel>
|
||||
<Select
|
||||
label="Vendor"
|
||||
value={vendorId}
|
||||
onChange={(e) => {
|
||||
setVendorId(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{vendors.map((v) => (
|
||||
<MenuItem key={String(v.id)} value={String(v.id)}>
|
||||
{v.companyName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label="From"
|
||||
value={dateFrom}
|
||||
onChange={(e) => {
|
||||
setDateFrom(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label="To"
|
||||
value={dateTo}
|
||||
onChange={(e) => {
|
||||
setDateTo(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<Button variant="text" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Stack>
|
||||
<VendorPosListControls
|
||||
filters={filters}
|
||||
vendors={vendors}
|
||||
totalCount={totalCount}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
onSearchChange={setSearch}
|
||||
onFilterChange={setFilter}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load vendor POs"}
|
||||
</Alert>
|
||||
)}
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">PO #</TableCell>
|
||||
<TableCell className="font-semibold">Dispatch #</TableCell>
|
||||
<TableCell className="font-semibold">Vendor</TableCell>
|
||||
<TableCell className="font-semibold">WO #</TableCell>
|
||||
<TableCell className="font-semibold">Location</TableCell>
|
||||
<TableCell className="font-semibold">NTE</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell className="font-semibold">Uplifts</TableCell>
|
||||
<TableCell className="font-semibold">Dispatched</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No vendor POs found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
key={String(row.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate(`/vendor-pos/${row.id}`)}
|
||||
>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/vendor-pos/${row.id}`);
|
||||
}}
|
||||
>
|
||||
{row.poNumber || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.dispatchNumber || "—"}</TableCell>
|
||||
<TableCell>{row.vendorName || "—"}</TableCell>
|
||||
<TableCell>{row.primaryWOInternalNumber || "—"}</TableCell>
|
||||
<TableCell>{row.locationName || "—"}</TableCell>
|
||||
<TableCell>{formatMoney(row.nteAmount)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={row.status || "—"} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.pendingUpliftCount > 0 ? (
|
||||
<Chip
|
||||
size="small"
|
||||
color="warning"
|
||||
label={`${row.pendingUpliftCount} pending`}
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(row.dispatchedAt)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<VendorPosListTable
|
||||
rows={rows}
|
||||
isLoading={isLoading}
|
||||
onNavigate={(id) => navigate(`/vendor-pos/${id}`)}
|
||||
/>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
onRowsPerPageChange={(event) => changePageSize(Number(event.target.value))}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
|||
166
src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts
vendored
Normal file
166
src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts
vendored
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm, useWatch, type FieldErrors, type Resolver } from "react-hook-form";
|
||||
import type { ZodError } from "zod";
|
||||
import {
|
||||
getChangedVendorValues,
|
||||
vendorUpdateSchema,
|
||||
type VendorFormValues,
|
||||
} from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||
import type { Vendor, VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const DEFAULT_VALUES: VendorFormValues = {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
companyPhone: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
totalJobs: 0,
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
};
|
||||
|
||||
function toFormValues(vendor: Vendor): VendorFormValues {
|
||||
return {
|
||||
companyName: vendor.companyName ?? "",
|
||||
contactName: vendor.contactName ?? "",
|
||||
email: vendor.email ?? "",
|
||||
phone: vendor.phone ?? "",
|
||||
companyPhone: vendor.companyPhone ?? "",
|
||||
googleMapsUrl: vendor.googleMapsUrl ?? "",
|
||||
notes: vendor.notes ?? "",
|
||||
totalJobs: vendor.totalJobs ?? 0,
|
||||
address: vendor.address ?? "",
|
||||
city: vendor.city ?? "",
|
||||
state: vendor.state ?? "",
|
||||
zip: vendor.zip ?? "",
|
||||
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
||||
isActive: vendor.isActive,
|
||||
companyId: vendor.companyId ?? null,
|
||||
preferredContact: vendor.preferredContact ?? "Phone",
|
||||
};
|
||||
}
|
||||
|
||||
function buildFieldErrors(error: ZodError): FieldErrors<VendorFormValues> {
|
||||
return error.issues.reduce<FieldErrors<VendorFormValues>>((fieldErrors, issue) => {
|
||||
const field = issue.path[0];
|
||||
if (typeof field === "string") {
|
||||
fieldErrors[field as keyof VendorFormValues] = {
|
||||
type: issue.code,
|
||||
message: issue.message,
|
||||
};
|
||||
}
|
||||
return fieldErrors;
|
||||
}, {});
|
||||
}
|
||||
|
||||
interface UseVendorDrawerFormProps {
|
||||
detail: Vendor | undefined;
|
||||
open: boolean;
|
||||
vendorId: string | number | undefined;
|
||||
onModeChange: (mode: "view" | "edit") => void;
|
||||
}
|
||||
|
||||
export function useVendorDetailDrawerForm({
|
||||
detail,
|
||||
open,
|
||||
vendorId,
|
||||
onModeChange,
|
||||
}: UseVendorDrawerFormProps) {
|
||||
const { data: facets } = useVendorFacets();
|
||||
const updateVendor = useUpdateVendor();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [original, setOriginal] = useState<VendorFormValues | null>(null);
|
||||
const originalRef = useRef<VendorFormValues | null>(null);
|
||||
const companies = useMemo(() => facets?.companies ?? [], [facets]);
|
||||
|
||||
const resolver = useCallback<Resolver<VendorFormValues>>(async (values) => {
|
||||
const result = vendorUpdateSchema.safeParse(
|
||||
getChangedVendorValues(values, originalRef.current),
|
||||
);
|
||||
return result.success
|
||||
? { values, errors: {} }
|
||||
: { values: {}, errors: buildFieldErrors(result.error) };
|
||||
}, []);
|
||||
|
||||
const form = useForm<VendorFormValues>({
|
||||
resolver,
|
||||
mode: "onChange",
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
});
|
||||
const { control, reset, setValue, trigger } = form;
|
||||
const currentValues = useWatch({ control });
|
||||
const changedValues = useMemo(
|
||||
() => getChangedVendorValues(currentValues, original),
|
||||
[currentValues, original],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && detail) {
|
||||
const values = toFormValues(detail);
|
||||
reset(values);
|
||||
setOriginal(values);
|
||||
originalRef.current = values;
|
||||
setSubmitError(null);
|
||||
} else if (!open) {
|
||||
setOriginal(null);
|
||||
originalRef.current = null;
|
||||
setSubmitError(null);
|
||||
}
|
||||
}, [open, detail, reset]);
|
||||
|
||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||
if (!company) return;
|
||||
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||
setValue("companyId", company.companyId, options);
|
||||
setValue("companyPhone", company.companyPhone, options);
|
||||
setValue("email", company.email, options);
|
||||
setValue("address", company.address, options);
|
||||
setValue("city", company.city, options);
|
||||
setValue("state", company.state, options);
|
||||
setValue("zip", company.zip, options);
|
||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||
void trigger();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (original) reset(original);
|
||||
setSubmitError(null);
|
||||
onModeChange("view");
|
||||
};
|
||||
|
||||
const submit = (values: VendorFormValues) => {
|
||||
if (vendorId === undefined || vendorId === "") return;
|
||||
const changed = getChangedVendorValues(values, original);
|
||||
if (Object.keys(changed).length === 0) return;
|
||||
setSubmitError(null);
|
||||
updateVendor.mutate(
|
||||
{ id: vendorId, values: changed },
|
||||
{
|
||||
onSuccess: () => onModeChange("view"),
|
||||
onError: (error: Error) => setSubmitError(error.message || "Failed to save vendor"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
...form,
|
||||
cancel,
|
||||
companies,
|
||||
hasChanges: Object.keys(changedValues).length > 0,
|
||||
isFormValid: vendorUpdateSchema.safeParse(changedValues).success,
|
||||
onCompanySelected,
|
||||
submit,
|
||||
submitError,
|
||||
updateVendor,
|
||||
};
|
||||
}
|
||||
338
src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx
vendored
Normal file
338
src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx
vendored
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import type { FormEventHandler, ReactNode } from "react";
|
||||
import { Controller, type Control, type FieldErrors } from "react-hook-form";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Link,
|
||||
Stack,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { VendorFormFields } from "./vendor-form-fields";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type {
|
||||
Vendor,
|
||||
VendorFacetCompany,
|
||||
VendorListItem,
|
||||
VendorPreferredContact,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
|
||||
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||
Phone: "Phone",
|
||||
Email: "Email",
|
||||
Text: "Text Message",
|
||||
};
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "—";
|
||||
const first = parts[0]?.[0] ?? "";
|
||||
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
vendor: VendorListItem | null;
|
||||
mode: "view" | "edit";
|
||||
pending: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function VendorDrawerHeader({ vendor, mode, pending, onClose }: HeaderProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<Box
|
||||
className="flex items-center justify-center"
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--accent, #1976d2)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{getInitials(vendor?.contactName ?? "")}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
id="vendor-detail-drawer-title"
|
||||
variant="subtitle1"
|
||||
component="h2"
|
||||
className="font-semibold"
|
||||
>
|
||||
{vendor?.companyName || "Vendor"}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{mode === "edit" ? "Editing vendor" : "Vendor details"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton aria-label="Close drawer" onClick={onClose} size="small" disabled={pending}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface ViewProps {
|
||||
detail: Vendor;
|
||||
mapsUrl: string | undefined;
|
||||
submitError: string | null;
|
||||
trades: string[];
|
||||
}
|
||||
|
||||
export function VendorDrawerView({ detail, mapsUrl, submitError, trades }: ViewProps) {
|
||||
return (
|
||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={detail.isActive ? "Active" : "Inactive"}
|
||||
color={detail.isActive ? "success" : "default"}
|
||||
/>
|
||||
{Boolean(trades[0]) && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={trades.length > 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<DetailSection title="Technician">
|
||||
<DetailField label="Technician Name" value={detail.contactName} />
|
||||
<DetailField label="Technician Phone" value={detail.phone} />
|
||||
<DetailField
|
||||
label="Preferred Contact"
|
||||
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title="Company">
|
||||
<DetailField label="Company" value={detail.companyName} />
|
||||
<DetailField label="Company Phone" value={detail.companyPhone} />
|
||||
<DetailField label="Email" value={detail.email} />
|
||||
</DetailSection>
|
||||
<DetailSection title="Location">
|
||||
<DetailField
|
||||
label="Address"
|
||||
value={[detail.address, detail.city, detail.state, detail.zip].filter(Boolean).join(", ")}
|
||||
/>
|
||||
{Boolean(mapsUrl) && (
|
||||
<Link
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
<LaunchIcon fontSize="small" />
|
||||
Open in Google Maps
|
||||
</Link>
|
||||
)}
|
||||
</DetailSection>
|
||||
<TradesSection trades={trades} />
|
||||
<DetailSection title="Activity">
|
||||
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
|
||||
</DetailSection>
|
||||
{Boolean(detail.notes) && (
|
||||
<DetailSection title="Notes">
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{detail.notes}
|
||||
</Typography>
|
||||
</DetailSection>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function TradesSection({ trades }: { trades: string[] }) {
|
||||
return (
|
||||
<DetailSection title="Trades">
|
||||
{trades.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
No trades specified.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box className="flex flex-wrap gap-1">
|
||||
{trades.map((trade, index) => (
|
||||
<Chip
|
||||
key={`${trade}-${index}`}
|
||||
size="small"
|
||||
label={index === 0 ? `${trade} (primary)` : trade}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditProps {
|
||||
control: Control<VendorFormValues>;
|
||||
errors: FieldErrors<VendorFormValues>;
|
||||
companies: VendorFacetCompany[];
|
||||
tradeOptions: string[];
|
||||
submitError: string | null;
|
||||
onCompanySelected: (company: VendorFacetCompany | null) => void;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
}
|
||||
|
||||
export function VendorDrawerEdit({
|
||||
control,
|
||||
errors,
|
||||
companies,
|
||||
tradeOptions,
|
||||
submitError,
|
||||
onCompanySelected,
|
||||
onSubmit,
|
||||
}: EditProps) {
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
id="vendor-edit-form"
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-1 flex-col overflow-y-auto"
|
||||
sx={{ px: 3, py: 3 }}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={Boolean(field.value)} onChange={field.onChange} color="success" />
|
||||
}
|
||||
label={field.value ? "Active" : "Inactive"}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<VendorFormFields
|
||||
control={control}
|
||||
errors={errors}
|
||||
companies={companies}
|
||||
tradeOptions={tradeOptions}
|
||||
onCompanySelected={onCompanySelected}
|
||||
showTotalJobs
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionsProps {
|
||||
mode: "view" | "edit";
|
||||
mapsUrl: string | undefined;
|
||||
pending: boolean;
|
||||
canSave: boolean;
|
||||
onCancel: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export function VendorDrawerActions({
|
||||
mode,
|
||||
mapsUrl,
|
||||
pending,
|
||||
canSave,
|
||||
onCancel,
|
||||
onEdit,
|
||||
}: ActionsProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
{mode === "view" ? (
|
||||
<>
|
||||
{Boolean(mapsUrl) && (
|
||||
<Tooltip title="Open in Google Maps">
|
||||
<Button
|
||||
component="a"
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="outlined"
|
||||
startIcon={<LaunchIcon />}
|
||||
>
|
||||
Maps
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box className="flex-1" />
|
||||
<Button variant="contained" onClick={onEdit} startIcon={<EditOutlinedIcon />}>
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outlined" onClick={onCancel} disabled={pending} fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
form="vendor-edit-form"
|
||||
disabled={pending || !canSave}
|
||||
fullWidth
|
||||
>
|
||||
{pending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="font-semibold uppercase"
|
||||
sx={{ color: "text.secondary", mb: 1 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>{children}</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: string }) {
|
||||
const display = value && value.trim() !== "" ? value : "—";
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{display}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +1,16 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useForm, useWatch, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import { Alert, Box, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Drawer,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Link,
|
||||
Stack,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { VendorFormFields } from "./vendor-form-fields";
|
||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||
VendorDrawerActions,
|
||||
VendorDrawerEdit,
|
||||
VendorDrawerHeader,
|
||||
VendorDrawerView,
|
||||
} from "./vendor-detail-drawer-content";
|
||||
import { useVendorDetailDrawerForm } from "./use-vendor-detail-drawer";
|
||||
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||
import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const DRAWER_WIDTH = 420;
|
||||
|
||||
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||
Phone: "Phone",
|
||||
Email: "Email",
|
||||
Text: "Text Message",
|
||||
};
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "—";
|
||||
const first = parts[0]?.[0] ?? "";
|
||||
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
function splitTrades(value: string | undefined): string[] {
|
||||
return (value ?? "")
|
||||
.split(",")
|
||||
|
|
@ -49,27 +18,6 @@ function splitTrades(value: string | undefined): string[] {
|
|||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toFormValues(vendor: Vendor): VendorFormValues {
|
||||
return {
|
||||
companyName: vendor.companyName ?? "",
|
||||
contactName: vendor.contactName ?? "",
|
||||
email: vendor.email ?? "",
|
||||
phone: vendor.phone ?? "",
|
||||
companyPhone: vendor.companyPhone ?? "",
|
||||
googleMapsUrl: vendor.googleMapsUrl ?? "",
|
||||
notes: vendor.notes ?? "",
|
||||
totalJobs: vendor.totalJobs ?? 0,
|
||||
address: vendor.address ?? "",
|
||||
city: vendor.city ?? "",
|
||||
state: vendor.state ?? "",
|
||||
zip: vendor.zip ?? "",
|
||||
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
||||
isActive: vendor.isActive,
|
||||
companyId: vendor.companyId ?? null,
|
||||
preferredContact: vendor.preferredContact ?? "Phone",
|
||||
};
|
||||
}
|
||||
|
||||
interface VendorDetailDrawerProps {
|
||||
open: boolean;
|
||||
vendor: VendorListItem | null;
|
||||
|
|
@ -88,109 +36,20 @@ export function VendorDetailDrawer({
|
|||
tradeOptions,
|
||||
}: VendorDetailDrawerProps) {
|
||||
const vendorId = vendor?.id;
|
||||
const { data: facets } = useVendorFacets();
|
||||
const {
|
||||
data: detail,
|
||||
isLoading,
|
||||
error,
|
||||
} = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined);
|
||||
const updateVendor = useUpdateVendor();
|
||||
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [original, setOriginal] = useState<VendorFormValues | null>(null);
|
||||
|
||||
const companies = useMemo(() => facets?.companies ?? [], [facets]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<VendorFormValues>({
|
||||
resolver: zodResolver(vendorSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
companyPhone: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
totalJobs: 0,
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
},
|
||||
});
|
||||
const currentValues = useWatch({ control });
|
||||
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && detail) {
|
||||
const values = toFormValues(detail);
|
||||
reset(values);
|
||||
setOriginal(values);
|
||||
setSubmitError(null);
|
||||
}
|
||||
if (!open) {
|
||||
setOriginal(null);
|
||||
setSubmitError(null);
|
||||
}
|
||||
}, [open, detail, reset]);
|
||||
|
||||
const onCompanySelected = (company: (typeof companies)[number] | null) => {
|
||||
if (!company) return;
|
||||
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||
setValue("companyId", company.companyId, options);
|
||||
setValue("companyPhone", company.companyPhone, options);
|
||||
setValue("email", company.email, options);
|
||||
setValue("address", company.address, options);
|
||||
setValue("city", company.city, options);
|
||||
setValue("state", company.state, options);
|
||||
setValue("zip", company.zip, options);
|
||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||
void trigger();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (original) {
|
||||
reset(original);
|
||||
}
|
||||
setSubmitError(null);
|
||||
onModeChange("view");
|
||||
};
|
||||
|
||||
const onSubmit = (values: VendorFormValues) => {
|
||||
if (vendorId === undefined || vendorId === null || vendorId === "") return;
|
||||
setSubmitError(null);
|
||||
updateVendor.mutate(
|
||||
{ id: vendorId, values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onModeChange("view");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setSubmitError(err.message || "Failed to save vendor");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const drawerForm = useVendorDetailDrawerForm({ detail, open, vendorId, onModeChange });
|
||||
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
|
||||
const mapsUrl = detail?.googleMapsUrl;
|
||||
const pending = drawerForm.updateVendor.isPending;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={updateVendor.isPending ? undefined : onClose}
|
||||
onClose={pending ? undefined : onClose}
|
||||
anchor="right"
|
||||
aria-labelledby="vendor-detail-drawer-title"
|
||||
ModalProps={{ keepMounted: false }}
|
||||
|
|
@ -202,56 +61,7 @@ export function VendorDetailDrawer({
|
|||
}}
|
||||
>
|
||||
<Stack className="flex h-full flex-col">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<Box
|
||||
className="flex items-center justify-center"
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--accent, #1976d2)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{getInitials(vendor?.contactName ?? "")}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
id="vendor-detail-drawer-title"
|
||||
variant="subtitle1"
|
||||
component="h2"
|
||||
className="font-semibold"
|
||||
>
|
||||
{vendor?.companyName || "Vendor"}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{mode === "edit" ? "Editing vendor" : "Vendor details"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton
|
||||
aria-label="Close drawer"
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
disabled={updateVendor.isPending}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<VendorDrawerHeader vendor={vendor} mode={mode} pending={pending} onClose={onClose} />
|
||||
{isLoading ? (
|
||||
<Box className="flex flex-1 items-center justify-center">
|
||||
<CircularProgress size={28} />
|
||||
|
|
@ -263,188 +73,31 @@ export function VendorDetailDrawer({
|
|||
) : detail ? (
|
||||
<>
|
||||
{mode === "view" ? (
|
||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={detail.isActive ? "Active" : "Inactive"}
|
||||
color={detail.isActive ? "success" : "default"}
|
||||
/>
|
||||
{Boolean(trades[0]) && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={trades.length > 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<DetailSection title="Technician">
|
||||
<DetailField label="Technician Name" value={detail.contactName} />
|
||||
<DetailField label="Technician Phone" value={detail.phone} />
|
||||
<DetailField
|
||||
label="Preferred Contact"
|
||||
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Company">
|
||||
<DetailField label="Company" value={detail.companyName} />
|
||||
<DetailField label="Company Phone" value={detail.companyPhone} />
|
||||
<DetailField label="Email" value={detail.email} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Location">
|
||||
<DetailField
|
||||
label="Address"
|
||||
value={[detail.address, detail.city, detail.state, detail.zip]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
/>
|
||||
{Boolean(mapsUrl) && (
|
||||
<Link
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
<LaunchIcon fontSize="small" />
|
||||
Open in Google Maps
|
||||
</Link>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Trades">
|
||||
{trades.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
No trades specified.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box className="flex flex-wrap gap-1">
|
||||
{trades.map((trade, idx) => (
|
||||
<Chip
|
||||
key={`${trade}-${idx}`}
|
||||
size="small"
|
||||
label={idx === 0 ? `${trade} (primary)` : trade}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Activity">
|
||||
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
|
||||
</DetailSection>
|
||||
|
||||
{Boolean(detail.notes) && (
|
||||
<DetailSection title="Notes">
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{detail.notes}
|
||||
</Typography>
|
||||
</DetailSection>
|
||||
)}
|
||||
</Stack>
|
||||
<VendorDrawerView
|
||||
detail={detail}
|
||||
mapsUrl={mapsUrl}
|
||||
submitError={drawerForm.submitError}
|
||||
trades={trades}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
component="form"
|
||||
id="vendor-edit-form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-1 flex-col overflow-y-auto"
|
||||
sx={{ px: 3, py: 3 }}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(field.value)}
|
||||
onChange={field.onChange}
|
||||
color="success"
|
||||
/>
|
||||
}
|
||||
label={field.value ? "Active" : "Inactive"}
|
||||
sx={{
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<VendorFormFields
|
||||
control={control}
|
||||
errors={errors}
|
||||
companies={companies}
|
||||
tradeOptions={tradeOptions}
|
||||
onCompanySelected={onCompanySelected}
|
||||
showTotalJobs
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<VendorDrawerEdit
|
||||
control={drawerForm.control}
|
||||
errors={drawerForm.formState.errors}
|
||||
companies={drawerForm.companies}
|
||||
tradeOptions={tradeOptions}
|
||||
submitError={drawerForm.submitError}
|
||||
onCompanySelected={drawerForm.onCompanySelected}
|
||||
onSubmit={drawerForm.handleSubmit(drawerForm.submit)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
{mode === "view" ? (
|
||||
<>
|
||||
{Boolean(mapsUrl) && (
|
||||
<Tooltip title="Open in Google Maps">
|
||||
<Button
|
||||
component="a"
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="outlined"
|
||||
startIcon={<LaunchIcon />}
|
||||
>
|
||||
Maps
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box className="flex-1" />
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => onModeChange("edit")}
|
||||
startIcon={<EditOutlinedIcon />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleCancel}
|
||||
disabled={updateVendor.isPending}
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
form="vendor-edit-form"
|
||||
disabled={updateVendor.isPending || !isFormValid}
|
||||
fullWidth
|
||||
>
|
||||
{updateVendor.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<VendorDrawerActions
|
||||
mode={mode}
|
||||
mapsUrl={mapsUrl}
|
||||
pending={pending}
|
||||
canSave={drawerForm.hasChanges && drawerForm.isFormValid}
|
||||
onCancel={drawerForm.cancel}
|
||||
onEdit={() => onModeChange("edit")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Box className="flex flex-1 items-center justify-center">
|
||||
|
|
@ -457,30 +110,3 @@ export function VendorDetailDrawer({
|
|||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="font-semibold uppercase"
|
||||
sx={{ color: "text.secondary", mb: 1 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>{children}</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: string }) {
|
||||
const display = value && value.trim() !== "" ? value : "—";
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{display}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState } from "react";
|
||||
import { NavLink } from "react-router";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Box, Drawer } from "@mui/material";
|
||||
|
||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||
import { getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
|
||||
import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
|
||||
|
||||
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||
if (isActive) {
|
||||
|
|
@ -31,88 +31,88 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
|||
|
||||
type NavItemProps = NavItemDef & {
|
||||
collapsed: boolean;
|
||||
end?: boolean;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
function NavItem({ label, icon: Icon, path, collapsed, end, badge }: NavItemProps) {
|
||||
function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isHover = hovered && !active;
|
||||
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
|
||||
|
||||
return (
|
||||
<NavLink to={path} end={end} className="no-underline" title={collapsed ? label : undefined}>
|
||||
{({ isActive }) => {
|
||||
const isHover = hovered && !isActive;
|
||||
const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={path}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="no-underline"
|
||||
title={collapsed ? label : undefined}
|
||||
>
|
||||
<Box
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
height: 40,
|
||||
gap: "12px",
|
||||
px: collapsed ? 0 : "12px",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 14,
|
||||
fontWeight: active ? 600 : 500,
|
||||
color: itemColor,
|
||||
backgroundColor: itemBg,
|
||||
boxShadow: active ? "var(--color-sidebar-item-active-shadow)" : "none",
|
||||
transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)",
|
||||
mb: "4px",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<Box
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
component="span"
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
height: 40,
|
||||
gap: "12px",
|
||||
px: collapsed ? 0 : "12px",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: itemColor,
|
||||
backgroundColor: itemBg,
|
||||
boxShadow: isActive ? "var(--color-sidebar-item-active-shadow)" : "none",
|
||||
transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)",
|
||||
mb: "4px",
|
||||
textAlign: "left",
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: 3,
|
||||
borderRadius: "0 3px 3px 0",
|
||||
backgroundColor: "var(--primary)",
|
||||
}}
|
||||
>
|
||||
{isActive && (
|
||||
/>
|
||||
)}
|
||||
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<Box className="truncate flex-1">{label}</Box>
|
||||
{badge != null && (
|
||||
<Box
|
||||
component="span"
|
||||
className="inline-flex shrink-0 items-center justify-center"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: 3,
|
||||
borderRadius: "0 3px 3px 0",
|
||||
backgroundColor: "var(--primary)",
|
||||
height: 18,
|
||||
minWidth: 18,
|
||||
px: "6px",
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "#ffffff",
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{badge}
|
||||
</Box>
|
||||
)}
|
||||
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<Box className="truncate flex-1">{label}</Box>
|
||||
{badge != null && (
|
||||
<Box
|
||||
component="span"
|
||||
className="inline-flex shrink-0 items-center justify-center"
|
||||
sx={{
|
||||
height: 18,
|
||||
minWidth: 18,
|
||||
px: "6px",
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "#ffffff",
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +122,8 @@ type AppSidebarProps = {
|
|||
|
||||
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||
const width = getSidebarWidth(collapsed);
|
||||
const { pathname } = useLocation();
|
||||
const activePath = getActiveNavPath(pathname);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
|
|
@ -195,7 +197,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
|
|||
path={path}
|
||||
badge={badge}
|
||||
collapsed={collapsed}
|
||||
end={path === "/"}
|
||||
active={path === activePath}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -59,3 +59,13 @@ export const navGroups: NavGroup[] = [
|
|||
// items: [{ label: "Documents", icon: FileText, path: "/documents" }],
|
||||
// },
|
||||
];
|
||||
|
||||
export function getActiveNavPath(pathname: string): string | undefined {
|
||||
return navGroups
|
||||
.flatMap((group) => group.items)
|
||||
.map((item) => item.path)
|
||||
.filter((path) =>
|
||||
path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`),
|
||||
)
|
||||
.sort((firstPath, secondPath) => secondPath.length - firstPath.length)[0];
|
||||
}
|
||||
|
|
|
|||
3
src/domain/vendors/api/vendors-api.ts
vendored
3
src/domain/vendors/api/vendors-api.ts
vendored
|
|
@ -8,6 +8,7 @@ import {
|
|||
mapVendorListItems,
|
||||
mapVendorPortalToken,
|
||||
mapVendorToBackend,
|
||||
mapVendorUpdateToBackend,
|
||||
} from "@/domain/vendors/mappers/vendor-mapper";
|
||||
import type {
|
||||
Vendor,
|
||||
|
|
@ -95,7 +96,7 @@ export const vendorsApi = {
|
|||
update: async (id: string | number, payload: unknown): Promise<Vendor> => {
|
||||
const data = await apiPut<unknown>(
|
||||
`${API_PATHS.rest.vendors}/${id}`,
|
||||
mapVendorToBackend(payload),
|
||||
mapVendorUpdateToBackend(payload),
|
||||
);
|
||||
return mapVendor(handleApiResponse(data));
|
||||
},
|
||||
|
|
|
|||
43
src/domain/vendors/mappers/vendor-mapper.ts
vendored
43
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -172,6 +172,49 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
|||
return payload;
|
||||
}
|
||||
|
||||
export function mapVendorUpdateToBackend(vendor: unknown): Record<string, unknown> {
|
||||
const item = asRecord(vendor);
|
||||
const payload: Record<string, unknown> = {};
|
||||
const fieldMappings = [
|
||||
["companyName", "CompanyName"],
|
||||
["contactName", "ContactName"],
|
||||
["email", "Email"],
|
||||
["googleMapsUrl", "GoogleMapsUrl"],
|
||||
["notes", "Notes"],
|
||||
["address", "Address"],
|
||||
["city", "City"],
|
||||
["state", "State"],
|
||||
["zip", "Zip"],
|
||||
["tradeSpecialties", "TradeSpecialties"],
|
||||
["isActive", "IsActive"],
|
||||
["preferredContact", "PreferredContact"],
|
||||
] as const;
|
||||
|
||||
fieldMappings.forEach(([target, source]) => {
|
||||
if (Object.prototype.hasOwnProperty.call(item, target)) payload[target] = item[target];
|
||||
if (Object.prototype.hasOwnProperty.call(item, source)) payload[target] = item[source];
|
||||
});
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(item, "companyId") ||
|
||||
Object.prototype.hasOwnProperty.call(item, "CompanyId")
|
||||
) {
|
||||
payload.companyId = readNumericCompanyId(item);
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(item, "phone") ||
|
||||
Object.prototype.hasOwnProperty.call(item, "Phone")
|
||||
) {
|
||||
payload.phone = toCanonicalPhone(item.phone ?? item.Phone);
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(item, "companyPhone") ||
|
||||
Object.prototype.hasOwnProperty.call(item, "CompanyPhone")
|
||||
) {
|
||||
payload.companyPhone = toCanonicalPhone(item.companyPhone ?? item.CompanyPhone);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function mapFacetCompany(raw: unknown): VendorFacetCompany {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
|
|
|
|||
39
src/domain/vendors/schemas/vendor-schema.ts
vendored
39
src/domain/vendors/schemas/vendor-schema.ts
vendored
|
|
@ -40,3 +40,42 @@ export const vendorSchema = z.object({
|
|||
});
|
||||
|
||||
export type VendorFormValues = z.infer<typeof vendorSchema>;
|
||||
|
||||
export const vendorUpdateSchema = vendorSchema.partial();
|
||||
|
||||
export type VendorUpdateValues = z.infer<typeof vendorUpdateSchema>;
|
||||
|
||||
const VENDOR_UPDATE_FIELDS = [
|
||||
"companyName",
|
||||
"contactName",
|
||||
"email",
|
||||
"phone",
|
||||
"companyPhone",
|
||||
"googleMapsUrl",
|
||||
"notes",
|
||||
"address",
|
||||
"city",
|
||||
"state",
|
||||
"zip",
|
||||
"tradeSpecialties",
|
||||
"isActive",
|
||||
"companyId",
|
||||
"preferredContact",
|
||||
] as const satisfies readonly (keyof VendorFormValues)[];
|
||||
|
||||
export function getChangedVendorValues(
|
||||
values: Partial<VendorFormValues>,
|
||||
original: VendorFormValues | null,
|
||||
): VendorUpdateValues {
|
||||
if (!original) return {};
|
||||
|
||||
return VENDOR_UPDATE_FIELDS.reduce<VendorUpdateValues>((changed, field) => {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(values, field) &&
|
||||
!Object.is(values[field], original[field])
|
||||
) {
|
||||
Object.assign(changed, { [field]: values[field] });
|
||||
}
|
||||
return changed;
|
||||
}, {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { VendorUpdateValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
interface UpdateVendorInput {
|
||||
export interface UpdateVendorInput {
|
||||
id: string | number;
|
||||
values: VendorFormValues;
|
||||
values: VendorUpdateValues;
|
||||
}
|
||||
|
||||
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {
|
||||
|
|
|
|||
20
src/test/config/menu.test.ts
Normal file
20
src/test/config/menu.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { getActiveNavPath } from "@/config/menu";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("getActiveNavPath", () => {
|
||||
it.each([
|
||||
["/", "/"],
|
||||
["/vendors", "/vendors"],
|
||||
["/vendors/42", "/vendors"],
|
||||
["/vendors/42/edit", "/vendors"],
|
||||
["/vendors/insights", "/vendors/insights"],
|
||||
["/vendors/preferences", "/vendors/preferences"],
|
||||
["/vendor-pos/42", "/vendor-pos"],
|
||||
])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => {
|
||||
expect(getActiveNavPath(pathname)).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it("does not match partial route segments", () => {
|
||||
expect(getActiveNavPath("/vendor-posts")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
27
src/test/domain/vendors/api/vendors-api.test.ts
vendored
27
src/test/domain/vendors/api/vendors-api.test.ts
vendored
|
|
@ -197,7 +197,7 @@ describe("vendorsApi.getList", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("preserves a legacy phone on read and unrelated write", async () => {
|
||||
it("preserves a legacy phone on read and omits it from an unrelated update", async () => {
|
||||
apiGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 7,
|
||||
|
|
@ -211,11 +211,26 @@ describe("vendorsApi.getList", () => {
|
|||
const vendor = await vendorsApi.getById(7);
|
||||
expect(vendor.phone).toBe("555-1234 ext 9");
|
||||
|
||||
await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" });
|
||||
expect(apiPut).toHaveBeenCalledWith(
|
||||
`${API_PATHS.rest.vendors}/7`,
|
||||
expect.objectContaining({ phone: "555-1234 ext 9", notes: "Updated only the notes" }),
|
||||
);
|
||||
await vendorsApi.update(7, { notes: "Updated only the notes" });
|
||||
expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`, {
|
||||
notes: "Updated only the notes",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends only changed fields and canonicalizes a supplied phone", async () => {
|
||||
apiPut.mockResolvedValueOnce({ data: {} });
|
||||
|
||||
await vendorsApi.update(7, {
|
||||
isActive: false,
|
||||
companyId: null,
|
||||
phone: "314-555-0198",
|
||||
});
|
||||
|
||||
expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`, {
|
||||
isActive: false,
|
||||
companyId: null,
|
||||
phone: "(314) 555-0198",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the deactivation-impact payload including linked work orders", async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import {
|
||||
getChangedVendorValues,
|
||||
vendorSchema,
|
||||
vendorUpdateSchema,
|
||||
} from "@/domain/vendors/schemas/vendor-schema";
|
||||
|
||||
const validVendor = {
|
||||
companyName: "Gateway Plumbing",
|
||||
|
|
@ -59,4 +63,26 @@ describe("vendorSchema", () => {
|
|||
it("rejects an unknown preferred contact value", () => {
|
||||
expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates only edited fields while retaining required and format checks", () => {
|
||||
expect(vendorUpdateSchema.safeParse({ isActive: true }).success).toBe(true);
|
||||
expect(vendorUpdateSchema.safeParse({ companyName: "" }).success).toBe(false);
|
||||
expect(vendorUpdateSchema.safeParse({ phone: "555-1234 ext 9" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a sparse update that preserves false and null and omits totalJobs", () => {
|
||||
const original = {
|
||||
...validVendor,
|
||||
totalJobs: 12,
|
||||
isActive: false,
|
||||
companyId: "co-1",
|
||||
preferredContact: "Phone" as const,
|
||||
};
|
||||
const changed = getChangedVendorValues(
|
||||
{ ...original, isActive: true, companyId: null, totalJobs: 13 },
|
||||
original,
|
||||
);
|
||||
|
||||
expect(changed).toEqual({ isActive: true, companyId: null });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue