refactor(vendors): satisfy maintainability gates

This commit is contained in:
Alexandre Brandizzi 2026-07-28 17:27:34 -03:00
parent b1cf3a0678
commit bcf63dffad
8 changed files with 927 additions and 740 deletions

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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,
};
}

View file

@ -1,322 +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",
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={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
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={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"
sx={{ width: { xs: "100%", sm: 240 }, minWidth: 180, flexShrink: 0 }}
>
<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 } }}
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
/>
<TextField
size="small"
type="date"
label="To"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setPage(0);
}}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
/>
<Button
variant="text"
onClick={clearFilters}
sx={{ minWidth: "max-content", flexShrink: 0 }}
>
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>

View 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,
};
}

View 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>
);
}

View file

@ -1,50 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useForm, useWatch, Controller, type FieldErrors, type Resolver } 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, 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 {
getChangedVendorValues,
vendorUpdateSchema,
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(",")
@ -52,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;
@ -91,141 +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 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),
);
if (result.success) return { values, errors: {} };
const errors = result.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;
},
{},
);
return { values: {}, errors };
}, []);
const {
control,
handleSubmit,
reset,
setValue,
trigger,
formState: { errors },
} = useForm<VendorFormValues>({
resolver,
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 changedValues = useMemo(
() => getChangedVendorValues(currentValues, original),
[currentValues, original],
);
const hasChanges = Object.keys(changedValues).length > 0;
const isFormValid = vendorUpdateSchema.safeParse(changedValues).success;
useEffect(() => {
if (open && detail) {
const values = toFormValues(detail);
reset(values);
setOriginal(values);
originalRef.current = values;
setSubmitError(null);
}
if (!open) {
setOriginal(null);
originalRef.current = 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;
const changed = getChangedVendorValues(values, original);
if (Object.keys(changed).length === 0) return;
setSubmitError(null);
updateVendor.mutate(
{ id: vendorId, values: changed },
{
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 }}
@ -237,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} />
@ -298,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 || !hasChanges || !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">
@ -492,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>
);
}

View file

@ -5,7 +5,7 @@ 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: VendorUpdateValues;
}