From f01a502956960c9532830a8574926b7c761881d8 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 12 Jun 2026 11:55:04 -0300 Subject: [PATCH] feat: add vendors CRUD and portal token panel --- .../vendors/_components/vendor-form.tsx | 205 +++++++++++++++++ .../_components/vendor-portal-token-panel.tsx | 169 ++++++++++++++ src/app/(protected)/vendors/edit/[id].tsx | 1 + src/app/(protected)/vendors/index.tsx | 215 ++++++++++++++++++ src/app/(protected)/vendors/new.tsx | 1 + src/domain/vendors/api/vendors-api.ts | 79 +++++++ src/domain/vendors/mappers/vendor-mapper.ts | 95 ++++++++ src/domain/vendors/schemas/vendor-schema.ts | 16 ++ src/domain/vendors/types/vendor.ts | 43 ++++ .../vendors/use-cases/use-create-vendor.ts | 24 ++ .../vendors/use-cases/use-delete-vendor.ts | 19 ++ .../vendors/use-cases/use-update-vendor.ts | 30 +++ .../vendors/use-cases/use-vendor-detail.ts | 13 ++ .../use-cases/use-vendor-portal-token.ts | 74 ++++++ .../vendors/use-cases/use-vendors-list.ts | 14 ++ 15 files changed, 998 insertions(+) create mode 100644 src/app/(protected)/vendors/_components/vendor-form.tsx create mode 100644 src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx create mode 100644 src/app/(protected)/vendors/edit/[id].tsx create mode 100644 src/app/(protected)/vendors/index.tsx create mode 100644 src/app/(protected)/vendors/new.tsx create mode 100644 src/domain/vendors/api/vendors-api.ts create mode 100644 src/domain/vendors/mappers/vendor-mapper.ts create mode 100644 src/domain/vendors/schemas/vendor-schema.ts create mode 100644 src/domain/vendors/types/vendor.ts create mode 100644 src/domain/vendors/use-cases/use-create-vendor.ts create mode 100644 src/domain/vendors/use-cases/use-delete-vendor.ts create mode 100644 src/domain/vendors/use-cases/use-update-vendor.ts create mode 100644 src/domain/vendors/use-cases/use-vendor-detail.ts create mode 100644 src/domain/vendors/use-cases/use-vendor-portal-token.ts create mode 100644 src/domain/vendors/use-cases/use-vendors-list.ts diff --git a/src/app/(protected)/vendors/_components/vendor-form.tsx b/src/app/(protected)/vendors/_components/vendor-form.tsx new file mode 100644 index 00000000..484aff74 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-form.tsx @@ -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({ + 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 ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit Vendor" : "Create Vendor"} + + {error && ( + + {error instanceof Error ? error.message : "Failed to load vendor"} + + )} + + + + + General Details + + + + + + + + + + + + Address + + + + + + + + + + Trade Specialties + + {trades.map((trade) => ( + toggleTrade(trade.value)} + /> + } + label={trade.value} + /> + ))} + + + + ( + } + label="Active" + /> + )} + /> + + {isEdit && id && } + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx b/src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx new file mode 100644 index 00000000..ed0e9b87 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-portal-token-panel.tsx @@ -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 ( + + + + Loading portal link… + + + ); + } + + if (isError) { + return Failed to load portal token; + } + + if (!tokenInfo) { + return ( + + + Vendor Portal + + + No active portal link. Generate one so this vendor can access the portal. + + + + ); + } + + return ( + + + Vendor Portal + + + + e.target.select()} + /> + + + + + Issued {new Date(tokenInfo.issuedAt).toLocaleDateString()} + {" · "}Expires {new Date(tokenInfo.expiresAt).toLocaleDateString()} + {tokenInfo.lastUsedAt + ? ` · Last used ${new Date(tokenInfo.lastUsedAt).toLocaleString()}` + : " · Never used"} + + + + + + + + setConfirmAction(null)}> + Rotate Portal Token + + + Rotate the portal token? The previous link will stop working immediately. + + + + + + + + + setConfirmAction(null)}> + Revoke Portal Tokens + + + Revoke all active portal tokens? The vendor will lose access until a new link is + generated. + + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/edit/[id].tsx b/src/app/(protected)/vendors/edit/[id].tsx new file mode 100644 index 00000000..43e82d49 --- /dev/null +++ b/src/app/(protected)/vendors/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/vendor-form"; diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx new file mode 100644 index 00000000..5f678eac --- /dev/null +++ b/src/app/(protected)/vendors/index.tsx @@ -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(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 ( + + + + + Vendors + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(e.target.value); + setPage(0); + }} + placeholder="Search vendors..." + className="min-w-sidebar" + /> + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load vendors"} + + )} + + + + + + Company + Contact + Email + Phone + Trades + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No vendors found + + + ) : ( + rows.map((row) => ( + + + + + {row.contactName || "—"} + {row.email || "—"} + {row.phone || "—"} + {row.tradeSpecialties || "—"} + + + + + + navigate(`/vendors/edit/${row.id}`)} + > + + + + + setDeleteTarget(row)} + > + + + + + + )) + )} + +
+
+ + setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(Number(e.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + + setDeleteTarget(null)}> + Delete Vendor + + + Are you sure you want to delete "{deleteTarget?.companyName}"? + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/vendors/new.tsx b/src/app/(protected)/vendors/new.tsx new file mode 100644 index 00000000..49113d8e --- /dev/null +++ b/src/app/(protected)/vendors/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/vendor-form"; diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts new file mode 100644 index 00000000..7544cc8e --- /dev/null +++ b/src/domain/vendors/api/vendors-api.ts @@ -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 { + return { + search: params.search ?? "", + page: params.page ?? 1, + pageSize: params.pageSize ?? 12, + }; +} + +export const vendorsApi = { + getList: async (params: VendorsListParams = {}): Promise => { + const data = await apiGet(API_PATHS.vendor.getList, { + searchParams: buildListParams(params), + }); + const paginated = handlePaginatedResponse(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 => { + const data = await apiGet(`${API_PATHS.rest.vendors}/${id}`); + return mapVendor(handleApiResponse(data)); + }, + + create: async (payload: unknown): Promise => { + const data = await apiPost(API_PATHS.rest.vendors, mapVendorToBackend(payload)); + return mapVendor(handleApiResponse(data)); + }, + + update: async (id: string | number, payload: unknown): Promise => { + const data = await apiPut( + `${API_PATHS.rest.vendors}/${id}`, + mapVendorToBackend(payload), + ); + return mapVendor(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiGet(API_PATHS.vendor.deleteVendor, { + searchParams: { id: String(id) }, + }); + }, + + getPortalToken: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.rest.vendors}/${id}/portal-token`); + return mapVendorPortalToken(handleApiResponse(data)); + }, + + rotatePortalToken: async (id: string | number): Promise => { + const data = await apiPost(`${API_PATHS.rest.vendors}/${id}/portal-token/rotate`); + return mapVendorPortalToken(handleApiResponse(data)); + }, + + revokePortalToken: async (id: string | number): Promise => { + await apiPost(`${API_PATHS.rest.vendors}/${id}/portal-token/revoke`); + }, +}; diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts new file mode 100644 index 00000000..695a0b44 --- /dev/null +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -0,0 +1,95 @@ +import type { Vendor, VendorListItem, VendorPortalToken } from "@/domain/vendors/types/vendor"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + } + return ""; +} + +function readId(record: Record): string | number { + const value = record.id ?? record.Id; + if (typeof value === "string" || typeof value === "number") return value; + return ""; +} + +function readBool(record: Record, ...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 { + const item = asRecord(vendor); + const payload: Record = { + 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, + }; +} diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts new file mode 100644 index 00000000..cb33e82d --- /dev/null +++ b/src/domain/vendors/schemas/vendor-schema.ts @@ -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; diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts new file mode 100644 index 00000000..71a41551 --- /dev/null +++ b/src/domain/vendors/types/vendor.ts @@ -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; +} diff --git a/src/domain/vendors/use-cases/use-create-vendor.ts b/src/domain/vendors/use-cases/use-create-vendor.ts new file mode 100644 index 00000000..03a64afc --- /dev/null +++ b/src/domain/vendors/use-cases/use-create-vendor.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/vendors/use-cases/use-delete-vendor.ts b/src/domain/vendors/use-cases/use-delete-vendor.ts new file mode 100644 index 00000000..20c7e5a5 --- /dev/null +++ b/src/domain/vendors/use-cases/use-delete-vendor.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/vendors/use-cases/use-update-vendor.ts b/src/domain/vendors/use-cases/use-update-vendor.ts new file mode 100644 index 00000000..7c6d6ba9 --- /dev/null +++ b/src/domain/vendors/use-cases/use-update-vendor.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/vendors/use-cases/use-vendor-detail.ts b/src/domain/vendors/use-cases/use-vendor-detail.ts new file mode 100644 index 00000000..1ae770ac --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendor-detail.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.vendors.detail(id ?? ""), + queryFn: () => vendorsApi.getById(requireQueryParam(id, "Vendor id")), + enabled: Boolean(id), + }); +} diff --git a/src/domain/vendors/use-cases/use-vendor-portal-token.ts b/src/domain/vendors/use-cases/use-vendor-portal-token.ts new file mode 100644 index 00000000..f27f660d --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendor-portal-token.ts @@ -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 { + 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 { + 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 { + 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 { + 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"); + }, + }); +} diff --git a/src/domain/vendors/use-cases/use-vendors-list.ts b/src/domain/vendors/use-cases/use-vendors-list.ts new file mode 100644 index 00000000..bd27c34b --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendors-list.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.vendors.list(params as Record), + queryFn: () => vendorsApi.getList(params), + placeholderData: keepPreviousData, + }); +}