mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 03:02:03 +00:00
feat: add vendors CRUD and portal token panel
This commit is contained in:
parent
a27e7607d3
commit
f01a502956
15 changed files with 998 additions and 0 deletions
205
src/app/(protected)/vendors/_components/vendor-form.tsx
vendored
Normal file
205
src/app/(protected)/vendors/_components/vendor-form.tsx
vendored
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
FormLabel,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
|
||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
|
||||
const defaultValues: VendorFormValues = {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
export default function VendorFormPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const { data: vendor, isLoading, error } = useVendorDetail(id);
|
||||
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||
const createVendor = useCreateVendor();
|
||||
const updateVendor = useUpdateVendor();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<VendorFormValues>({
|
||||
resolver: zodResolver(vendorSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (vendor && isEdit) {
|
||||
reset({
|
||||
companyName: vendor.companyName,
|
||||
contactName: vendor.contactName,
|
||||
email: vendor.email,
|
||||
phone: vendor.phone,
|
||||
address: vendor.address,
|
||||
city: vendor.city,
|
||||
state: vendor.state,
|
||||
zip: vendor.zip,
|
||||
tradeSpecialties: vendor.tradeSpecialties,
|
||||
isActive: vendor.isActive,
|
||||
});
|
||||
}
|
||||
}, [vendor, isEdit, reset]);
|
||||
|
||||
const tradeSpecialties = useWatch({ control, name: "tradeSpecialties" });
|
||||
const selectedTrades = (tradeSpecialties || "")
|
||||
.split(",")
|
||||
.map((trade) => trade.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const toggleTrade = (tradeValue: string) => {
|
||||
const updated = selectedTrades.includes(tradeValue)
|
||||
? selectedTrades.filter((trade) => trade !== tradeValue)
|
||||
: [...selectedTrades, tradeValue];
|
||||
setValue("tradeSpecialties", updated.join(", "));
|
||||
};
|
||||
|
||||
const isSaving = createVendor.isPending || updateVendor.isPending;
|
||||
|
||||
const onSubmit = (values: VendorFormValues) => {
|
||||
if (isEdit && id) {
|
||||
updateVendor.mutate({ id, values });
|
||||
return;
|
||||
}
|
||||
createVendor.mutate(values);
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-10">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="mx-auto max-w-4xl p-4">
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Stack spacing={4}>
|
||||
<Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load vendor"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
General Details
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="Company Name"
|
||||
required
|
||||
fullWidth
|
||||
error={Boolean(errors.companyName)}
|
||||
helperText={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<TextField label="Contact Name" fullWidth {...register("contactName")} />
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="Email"
|
||||
required
|
||||
fullWidth
|
||||
type="email"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<TextField label="Phone" fullWidth {...register("phone")} />
|
||||
</Stack>
|
||||
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Address
|
||||
</Typography>
|
||||
<TextField label="Address" fullWidth {...register("address")} />
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField label="City" fullWidth {...register("city")} />
|
||||
<TextField label="State" fullWidth placeholder="e.g., IL" {...register("state")} />
|
||||
<TextField label="Zip" fullWidth {...register("zip")} />
|
||||
</Stack>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel component="legend">Trade Specialties</FormLabel>
|
||||
<Box className="mt-2 flex flex-wrap gap-2">
|
||||
{trades.map((trade) => (
|
||||
<FormControlLabel
|
||||
key={String(trade.id)}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedTrades.includes(trade.value)}
|
||||
onChange={() => toggleTrade(trade.value)}
|
||||
/>
|
||||
}
|
||||
label={trade.value}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</FormGroup>
|
||||
|
||||
<Controller
|
||||
name="isActive"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={field.value} onChange={field.onChange} />}
|
||||
label="Active"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />}
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
169
src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx
vendored
Normal file
169
src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx
vendored
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
useGenerateVendorPortalToken,
|
||||
useRevokeVendorPortalToken,
|
||||
useRotateVendorPortalToken,
|
||||
useVendorPortalToken,
|
||||
} from "@/domain/vendors/use-cases/use-vendor-portal-token";
|
||||
|
||||
interface VendorPortalTokenPanelProps {
|
||||
vendorId: string | number;
|
||||
}
|
||||
|
||||
export function VendorPortalTokenPanel({ vendorId }: VendorPortalTokenPanelProps) {
|
||||
const { data: tokenInfo, isLoading, isError } = useVendorPortalToken(vendorId);
|
||||
const rotate = useRotateVendorPortalToken(vendorId);
|
||||
const revoke = useRevokeVendorPortalToken(vendorId);
|
||||
const generate = useGenerateVendorPortalToken(vendorId);
|
||||
|
||||
const [confirmAction, setConfirmAction] = useState<"rotate" | "revoke" | null>(null);
|
||||
|
||||
const busy = rotate.isPending || revoke.isPending || generate.isPending;
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!tokenInfo?.portalUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(tokenInfo.portalUrl);
|
||||
} catch {
|
||||
// clipboard may be unavailable
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary" className="mt-2">
|
||||
Loading portal link…
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Alert severity="error">Failed to load portal token</Alert>;
|
||||
}
|
||||
|
||||
if (!tokenInfo) {
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Vendor Portal
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1 mb-3">
|
||||
No active portal link. Generate one so this vendor can access the portal.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => generate.mutate()} disabled={busy}>
|
||||
Generate Link
|
||||
</Button>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Vendor Portal
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} className="mb-2">
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={tokenInfo.portalUrl}
|
||||
slotProps={{ input: { readOnly: true } }}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<Button variant="outlined" onClick={() => void copyUrl()}>
|
||||
Copy
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" display="block" className="mb-3">
|
||||
Issued {new Date(tokenInfo.issuedAt).toLocaleDateString()}
|
||||
{" · "}Expires {new Date(tokenInfo.expiresAt).toLocaleDateString()}
|
||||
{tokenInfo.lastUsedAt
|
||||
? ` · Last used ${new Date(tokenInfo.lastUsedAt).toLocaleString()}`
|
||||
: " · Never used"}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => setConfirmAction("rotate")}
|
||||
disabled={busy}
|
||||
>
|
||||
Rotate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setConfirmAction("revoke")}
|
||||
disabled={busy}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Dialog open={confirmAction === "rotate"} onClose={() => setConfirmAction(null)}>
|
||||
<DialogTitle>Rotate Portal Token</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Rotate the portal token? The previous link will stop working immediately.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmAction(null)}>Cancel</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
rotate.mutate();
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
>
|
||||
Rotate
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={confirmAction === "revoke"} onClose={() => setConfirmAction(null)}>
|
||||
<DialogTitle>Revoke Portal Tokens</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Revoke all active portal tokens? The vendor will lose access until a new link is
|
||||
generated.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmAction(null)}>Cancel</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
revoke.mutate();
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/vendors/edit/[id].tsx
vendored
Normal file
1
src/app/(protected)/vendors/edit/[id].tsx
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/vendor-form";
|
||||
215
src/app/(protected)/vendors/index.tsx
vendored
Normal file
215
src/app/(protected)/vendors/index.tsx
vendored
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
export default function VendorsListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const deleteVendor = useDeleteVendor();
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({ search: debouncedSearch, page: page + 1, pageSize }),
|
||||
[debouncedSearch, page, pageSize],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, error } = useVendorsList(listParams);
|
||||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteTarget) return;
|
||||
deleteVendor.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Typography variant="h5" component="h1">
|
||||
Vendors
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search vendors..."
|
||||
className="min-w-sidebar"
|
||||
/>
|
||||
<Button variant="contained" onClick={() => navigate("/vendors/new")}>
|
||||
+ New Vendor
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load vendors"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Company</TableCell>
|
||||
<TableCell className="font-semibold">Contact</TableCell>
|
||||
<TableCell className="font-semibold">Email</TableCell>
|
||||
<TableCell className="font-semibold">Phone</TableCell>
|
||||
<TableCell className="font-semibold">Trades</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No vendors found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={() => navigate(`/vendors/edit/${row.id}`)}
|
||||
>
|
||||
{row.companyName || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.contactName || "—"}</TableCell>
|
||||
<TableCell>{row.email || "—"}</TableCell>
|
||||
<TableCell>{row.phone || "—"}</TableCell>
|
||||
<TableCell>{row.tradeSpecialties || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={row.isActive ? "Active" : "Inactive"}
|
||||
color={row.isActive ? "success" : "default"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Edit vendor"
|
||||
onClick={() => navigate(`/vendors/edit/${row.id}`)}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Delete vendor"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Vendor</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete "{deleteTarget?.companyName}"?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteVendor.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteVendor.isPending}
|
||||
>
|
||||
{deleteVendor.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/vendors/new.tsx
vendored
Normal file
1
src/app/(protected)/vendors/new.tsx
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/vendor-form";
|
||||
79
src/domain/vendors/api/vendors-api.ts
vendored
Normal file
79
src/domain/vendors/api/vendors-api.ts
vendored
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiGet, apiPost, apiPut } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
mapVendor,
|
||||
mapVendorListItems,
|
||||
mapVendorPortalToken,
|
||||
mapVendorToBackend,
|
||||
} from "@/domain/vendors/mappers/vendor-mapper";
|
||||
import type {
|
||||
Vendor,
|
||||
VendorPortalToken,
|
||||
VendorsListParams,
|
||||
VendorsListResult,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
|
||||
function buildListParams(params: VendorsListParams): Record<string, string | number> {
|
||||
return {
|
||||
search: params.search ?? "",
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 12,
|
||||
};
|
||||
}
|
||||
|
||||
export const vendorsApi = {
|
||||
getList: async (params: VendorsListParams = {}): Promise<VendorsListResult> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.vendor.getList, {
|
||||
searchParams: buildListParams(params),
|
||||
});
|
||||
const paginated = handlePaginatedResponse<unknown>(data, {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
});
|
||||
return {
|
||||
items: mapVendorListItems(paginated.items),
|
||||
totalCount: paginated.totalCount,
|
||||
page: paginated.page,
|
||||
pageSize: paginated.pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string | number): Promise<Vendor> => {
|
||||
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}`);
|
||||
return mapVendor(handleApiResponse(data));
|
||||
},
|
||||
|
||||
create: async (payload: unknown): Promise<Vendor> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.rest.vendors, mapVendorToBackend(payload));
|
||||
return mapVendor(handleApiResponse(data));
|
||||
},
|
||||
|
||||
update: async (id: string | number, payload: unknown): Promise<Vendor> => {
|
||||
const data = await apiPut<unknown>(
|
||||
`${API_PATHS.rest.vendors}/${id}`,
|
||||
mapVendorToBackend(payload),
|
||||
);
|
||||
return mapVendor(handleApiResponse(data));
|
||||
},
|
||||
|
||||
delete: async (id: string | number): Promise<void> => {
|
||||
await apiGet<unknown>(API_PATHS.vendor.deleteVendor, {
|
||||
searchParams: { id: String(id) },
|
||||
});
|
||||
},
|
||||
|
||||
getPortalToken: async (id: string | number): Promise<VendorPortalToken | null> => {
|
||||
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token`);
|
||||
return mapVendorPortalToken(handleApiResponse(data));
|
||||
},
|
||||
|
||||
rotatePortalToken: async (id: string | number): Promise<VendorPortalToken | null> => {
|
||||
const data = await apiPost<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token/rotate`);
|
||||
return mapVendorPortalToken(handleApiResponse(data));
|
||||
},
|
||||
|
||||
revokePortalToken: async (id: string | number): Promise<void> => {
|
||||
await apiPost<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token/revoke`);
|
||||
},
|
||||
};
|
||||
95
src/domain/vendors/mappers/vendor-mapper.ts
vendored
Normal file
95
src/domain/vendors/mappers/vendor-mapper.ts
vendored
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { Vendor, VendorListItem, VendorPortalToken } from "@/domain/vendors/types/vendor";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number") return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readId(record: Record<string, unknown>): string | number {
|
||||
const value = record.id ?? record.Id;
|
||||
if (typeof value === "string" || typeof value === "number") return value;
|
||||
return "";
|
||||
}
|
||||
|
||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mapVendorListItem(raw: unknown): VendorListItem {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
id: readId(item),
|
||||
companyName: readString(item, "companyName", "CompanyName"),
|
||||
contactName: readString(item, "contactName", "ContactName"),
|
||||
email: readString(item, "email", "Email"),
|
||||
phone: readString(item, "phone", "Phone"),
|
||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
isActive: readBool(item, "isActive", "IsActive"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVendorListItems(rawItems: unknown[]): VendorListItem[] {
|
||||
return rawItems.map(mapVendorListItem);
|
||||
}
|
||||
|
||||
export function mapVendor(raw: unknown): Vendor {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
id: readId(item) || undefined,
|
||||
companyName: readString(item, "companyName", "CompanyName"),
|
||||
contactName: readString(item, "contactName", "ContactName"),
|
||||
email: readString(item, "email", "Email"),
|
||||
phone: readString(item, "phone", "Phone"),
|
||||
address: readString(item, "address", "Address"),
|
||||
city: readString(item, "city", "City"),
|
||||
state: readString(item, "state", "State"),
|
||||
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
isActive: readBool(item, "isActive", "IsActive"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
||||
const item = asRecord(vendor);
|
||||
const payload: Record<string, unknown> = {
|
||||
companyName: item.companyName ?? "",
|
||||
contactName: item.contactName ?? "",
|
||||
email: item.email ?? "",
|
||||
phone: item.phone ?? "",
|
||||
address: item.address ?? "",
|
||||
city: item.city ?? "",
|
||||
state: item.state ?? "",
|
||||
zip: item.zip ?? "",
|
||||
tradeSpecialties: item.tradeSpecialties ?? "",
|
||||
isActive: item.isActive ?? true,
|
||||
};
|
||||
const id = item.id ?? item.Id;
|
||||
if (typeof id === "string" || typeof id === "number") {
|
||||
payload.id = id;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
|
||||
const item = asRecord(raw);
|
||||
const portalUrl = readString(item, "portalUrl", "PortalUrl");
|
||||
if (!portalUrl) return null;
|
||||
return {
|
||||
portalUrl,
|
||||
issuedAt: readString(item, "issuedAt", "IssuedAt"),
|
||||
expiresAt: readString(item, "expiresAt", "ExpiresAt"),
|
||||
lastUsedAt: readString(item, "lastUsedAt", "LastUsedAt") || undefined,
|
||||
};
|
||||
}
|
||||
16
src/domain/vendors/schemas/vendor-schema.ts
vendored
Normal file
16
src/domain/vendors/schemas/vendor-schema.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const vendorSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
contactName: z.string().optional(),
|
||||
email: z.string().min(1, "Email is required").email("Invalid email"),
|
||||
phone: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
zip: z.string().optional(),
|
||||
tradeSpecialties: z.string().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type VendorFormValues = z.infer<typeof vendorSchema>;
|
||||
43
src/domain/vendors/types/vendor.ts
vendored
Normal file
43
src/domain/vendors/types/vendor.ts
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export interface VendorListItem {
|
||||
id: string | number;
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
tradeSpecialties: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface Vendor {
|
||||
id?: string | number;
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
tradeSpecialties: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface VendorsListParams {
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface VendorsListResult {
|
||||
items: VendorListItem[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface VendorPortalToken {
|
||||
portalUrl: string;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
lastUsedAt?: string;
|
||||
}
|
||||
24
src/domain/vendors/use-cases/use-create-vendor.ts
vendored
Normal file
24
src/domain/vendors/use-cases/use-create-vendor.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (values: VendorFormValues) => vendorsApi.create(values),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||
toast.success("Vendor created");
|
||||
navigate("/vendors");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create vendor");
|
||||
},
|
||||
});
|
||||
}
|
||||
19
src/domain/vendors/use-cases/use-delete-vendor.ts
vendored
Normal file
19
src/domain/vendors/use-cases/use-delete-vendor.ts
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useDeleteVendor(): UseMutationResult<void, Error, string | number> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string | number) => vendorsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||
toast.success("Vendor deleted");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to delete vendor");
|
||||
},
|
||||
});
|
||||
}
|
||||
30
src/domain/vendors/use-cases/use-update-vendor.ts
vendored
Normal file
30
src/domain/vendors/use-cases/use-update-vendor.ts
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
interface UpdateVendorInput {
|
||||
id: string | number;
|
||||
values: VendorFormValues;
|
||||
}
|
||||
|
||||
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, values }: UpdateVendorInput) => vendorsApi.update(id, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.detail(variables.id) });
|
||||
toast.success("Vendor updated");
|
||||
navigate("/vendors");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update vendor");
|
||||
},
|
||||
});
|
||||
}
|
||||
13
src/domain/vendors/use-cases/use-vendor-detail.ts
vendored
Normal file
13
src/domain/vendors/use-cases/use-vendor-detail.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||
|
||||
export function useVendorDetail(id: string | undefined): UseQueryResult<Vendor, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.vendors.detail(id ?? ""),
|
||||
queryFn: () => vendorsApi.getById(requireQueryParam(id, "Vendor id")),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
74
src/domain/vendors/use-cases/use-vendor-portal-token.ts
vendored
Normal file
74
src/domain/vendors/use-cases/use-vendor-portal-token.ts
vendored
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { VendorPortalToken } from "@/domain/vendors/types/vendor";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||
|
||||
export function useVendorPortalToken(
|
||||
vendorId: string | number | undefined,
|
||||
): UseQueryResult<VendorPortalToken | null, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.vendors.portalToken(vendorId ?? ""),
|
||||
queryFn: () => vendorsApi.getPortalToken(requireQueryParam(vendorId, "Vendor id")),
|
||||
enabled: vendorId !== undefined && vendorId !== "",
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRotateVendorPortalToken(
|
||||
vendorId: string | number,
|
||||
): UseMutationResult<VendorPortalToken | null, Error, void> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => vendorsApi.rotatePortalToken(vendorId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.portalToken(vendorId) });
|
||||
toast.success("Portal token rotated");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Rotate failed");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeVendorPortalToken(
|
||||
vendorId: string | number,
|
||||
): UseMutationResult<void, Error, void> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => vendorsApi.revokePortalToken(vendorId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.portalToken(vendorId) });
|
||||
toast.success("Portal tokens revoked");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Revoke failed");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateVendorPortalToken(
|
||||
vendorId: string | number,
|
||||
): UseMutationResult<VendorPortalToken | null, Error, void> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => vendorsApi.getPortalToken(vendorId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.portalToken(vendorId) });
|
||||
toast.success("Portal link generated");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to generate token");
|
||||
},
|
||||
});
|
||||
}
|
||||
14
src/domain/vendors/use-cases/use-vendors-list.ts
vendored
Normal file
14
src/domain/vendors/use-cases/use-vendors-list.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { VendorsListParams, VendorsListResult } from "@/domain/vendors/types/vendor";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useVendorsList(
|
||||
params: VendorsListParams,
|
||||
): UseQueryResult<VendorsListResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.vendors.list(params as Record<string, unknown>),
|
||||
queryFn: () => vendorsApi.getList(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue