mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 12:43:13 +00:00
refactor(vendors): satisfy maintainability gates
This commit is contained in:
parent
b1cf3a0678
commit
bcf63dffad
8 changed files with 927 additions and 740 deletions
|
|
@ -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,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>
|
||||
|
|
|
|||
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,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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue