diff --git a/src/app/(protected)/accounts/_components/account-form.tsx b/src/app/(protected)/accounts/_components/account-form.tsx new file mode 100644 index 00000000..7cfd1aba --- /dev/null +++ b/src/app/(protected)/accounts/_components/account-form.tsx @@ -0,0 +1,182 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Alert, + Box, + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { accountSchema, type AccountFormValues } from "@/domain/accounts/schemas/account-schema"; +import { useAccountDetail } from "@/domain/accounts/use-cases/use-account-detail"; +import { useCreateAccount } from "@/domain/accounts/use-cases/use-create-account"; +import { useUpdateAccount } from "@/domain/accounts/use-cases/use-update-account"; + +const defaultValues: AccountFormValues = { + AccountName: "", + AccountNumber: "", + Industry: "", + Website: "", + Notes: "", + Address1: "", + Address2: "", + City: "", + State: "", + Zip: "", + Country: "US", +}; + +export default function AccountFormPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const isEdit = Boolean(id); + + const { data: account, isLoading, error } = useAccountDetail(id); + const createAccount = useCreateAccount(); + const updateAccount = useUpdateAccount(); + + const { + register, + handleSubmit, + reset, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(accountSchema), + defaultValues, + }); + + useEffect(() => { + if (account && isEdit) { + reset({ + AccountName: account.AccountName ?? "", + AccountNumber: account.AccountNumber ?? "", + Industry: account.Industry ?? "", + Website: account.Website ?? "", + Notes: account.Notes ?? "", + Address1: account.Address1 ?? "", + Address2: account.Address2 ?? "", + City: account.City ?? "", + State: account.State ?? "", + Zip: account.Zip ?? "", + Country: account.Country ?? "US", + }); + } + }, [account, isEdit, reset]); + + const isSaving = createAccount.isPending || updateAccount.isPending; + + const onSubmit = (values: AccountFormValues) => { + if (isEdit && id) { + updateAccount.mutate({ id, values }); + return; + } + createAccount.mutate(values); + }; + + if (isEdit && isLoading) { + return ( + + + + ); + } + + return ( + + + + + + {isEdit ? "Edit Account" : "Create an Account"} + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load account"} + + )} + + + + + General Details + + + + + + + + + + + + + + + Address + + + + + + + + + + + ( + + Country + + + )} + /> + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/accounts/edit/[id].tsx b/src/app/(protected)/accounts/edit/[id].tsx new file mode 100644 index 00000000..8a48c8a9 --- /dev/null +++ b/src/app/(protected)/accounts/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/account-form"; diff --git a/src/app/(protected)/accounts/index.tsx b/src/app/(protected)/accounts/index.tsx new file mode 100644 index 00000000..a0ee36b6 --- /dev/null +++ b/src/app/(protected)/accounts/index.tsx @@ -0,0 +1,221 @@ +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, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; +import { useDeleteAccount } from "@/domain/accounts/use-cases/use-delete-account"; +import { useDebounce } from "@/hooks/use-debounce"; + +export default function AccountsListPage() { + 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 deleteAccount = useDeleteAccount(); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + }), + [debouncedSearch, page, pageSize], + ); + + const { data, isLoading, isFetching, error } = useAccountsList(listParams); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + const handleDeleteConfirm = () => { + if (!deleteTarget) { + return; + } + + deleteAccount.mutate(deleteTarget.id, { + onSuccess: () => setDeleteTarget(null), + }); + }; + + return ( + + + + + Accounts + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search accounts..." + className="min-w-[220px]" + /> + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load accounts"} + + )} + + + + + + Name + Number + Industry + Website + Primary Contact + Phone + Owner + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No accounts found + + + ) : ( + rows.map((row) => ( + + + + + {row.number || "—"} + {row.industry || "—"} + {row.website || "—"} + {row.primaryContact || "—"} + {row.phoneNumber || "—"} + {row.owner || "—"} + + + navigate(`/accounts/edit/${row.id}`)} + > + + + + + setDeleteTarget(row)} + > + + + + + + )) + )} + +
+
+ + setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + + setDeleteTarget(null)}> + Delete Account + + + Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be + undone. + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/accounts/new.tsx b/src/app/(protected)/accounts/new.tsx new file mode 100644 index 00000000..6f506b87 --- /dev/null +++ b/src/app/(protected)/accounts/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/account-form"; diff --git a/src/domain/accounts/api/accounts-api.ts b/src/domain/accounts/api/accounts-api.ts new file mode 100644 index 00000000..c03fdac0 --- /dev/null +++ b/src/domain/accounts/api/accounts-api.ts @@ -0,0 +1,61 @@ +import { API_PATHS } from "@/api/api-paths"; +import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; +import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; +import { + mapAccount, + mapAccountListItems, + mapAccountToBackend, +} from "@/domain/accounts/mappers/account-mapper"; +import type { + Account, + AccountsListParams, + AccountsListResult, +} from "@/domain/accounts/types/account"; + +function buildSearchParams(params: AccountsListParams): Record { + return { + search: params.search ?? "", + page: params.page ?? 1, + pageSize: params.pageSize ?? 12, + }; +} + +export const accountsApi = { + getList: async (params: AccountsListParams = {}): Promise => { + const searchParams = buildSearchParams(params); + const data = await apiGet(API_PATHS.account.getList, { searchParams }); + const paginated = handlePaginatedResponse(data, { + page: params.page, + pageSize: params.pageSize, + }); + + return { + items: mapAccountListItems(paginated.items), + totalCount: paginated.totalCount, + page: paginated.page, + pageSize: paginated.pageSize, + }; + }, + + getById: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.rest.accounts}/${id}`); + return mapAccount(handleApiResponse(data)); + }, + + create: async (payload: unknown): Promise => { + const data = await apiPost(API_PATHS.rest.accounts, mapAccountToBackend(payload)); + return mapAccount(handleApiResponse(data)); + }, + + update: async (id: string | number, payload: unknown): Promise => { + const data = await apiPut( + `${API_PATHS.rest.accounts}/${id}`, + mapAccountToBackend(payload), + ); + return mapAccount(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiDelete(`${API_PATHS.rest.accounts}/${id}`); + }, +}; diff --git a/src/domain/accounts/mappers/account-mapper.ts b/src/domain/accounts/mappers/account-mapper.ts new file mode 100644 index 00000000..100e36d9 --- /dev/null +++ b/src/domain/accounts/mappers/account-mapper.ts @@ -0,0 +1,88 @@ +import type { Account, AccountListItem } from "@/domain/accounts/types/account"; + +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 ""; +} + +export function mapAccountListItem(raw: unknown): AccountListItem { + const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + + return { + id: readId(item), + name: readString(item, "name", "Name", "AccountName"), + number: readString(item, "number", "AccountNumber"), + industry: readString(item, "industry", "Industry"), + website: readString(item, "website", "Website"), + primaryContact: readString(item, "primaryContact", "PrimaryContact"), + phoneNumber: readString(item, "phoneNumber", "Phone", "PhoneNumber"), + owner: readString(item, "owner", "Owner"), + }; +} + +export function mapAccountListItems(rawItems: unknown[]): AccountListItem[] { + return rawItems.map(mapAccountListItem); +} + +export function mapAccount(raw: unknown): Account { + const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + + return { + id: readId(item) || undefined, + AccountName: readString(item, "AccountName", "accountName", "name", "Name") || undefined, + AccountNumber: readString(item, "AccountNumber", "accountNumber", "number") || undefined, + Industry: readString(item, "Industry", "industry") || undefined, + Website: readString(item, "Website", "website") || undefined, + Notes: readString(item, "Notes", "notes") || undefined, + Address1: readString(item, "Address1", "address1") || undefined, + Address2: readString(item, "Address2", "address2") || undefined, + City: readString(item, "City", "city") || undefined, + State: readString(item, "State", "state") || undefined, + Zip: readString(item, "Zip", "zip", "zipCode") || undefined, + Country: readString(item, "Country", "country") || undefined, + }; +} + +export function mapAccountToBackend(account: unknown): Record { + const item = (typeof account === "object" && account !== null ? account : {}) as Record< + string, + unknown + >; + + const payload: Record = { + AccountName: item.AccountName ?? "", + AccountNumber: item.AccountNumber ?? "", + Industry: item.Industry ?? "", + Website: item.Website ?? "", + Notes: item.Notes ?? "", + Address1: item.Address1 ?? "", + Address2: item.Address2 ?? "", + City: item.City ?? "", + State: item.State ?? "", + Zip: item.Zip ?? "", + Country: item.Country ?? "US", + }; + + const id = item.id ?? item.Id; + if (typeof id === "string" || typeof id === "number") { + payload.id = id; + } + + return payload; +} diff --git a/src/domain/accounts/schemas/account-schema.ts b/src/domain/accounts/schemas/account-schema.ts new file mode 100644 index 00000000..4bfb0972 --- /dev/null +++ b/src/domain/accounts/schemas/account-schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const accountSchema = z.object({ + AccountName: z.string().min(1, "Account Name is required"), + AccountNumber: z.string().optional(), + Industry: z.string().optional(), + Website: z.string().optional(), + Notes: z.string().optional(), + Address1: z.string().optional(), + Address2: z.string().optional(), + City: z.string().optional(), + State: z.string().optional(), + Zip: z.string().optional(), + Country: z.string().optional(), +}); + +export type AccountFormValues = z.infer; diff --git a/src/domain/accounts/types/account.ts b/src/domain/accounts/types/account.ts new file mode 100644 index 00000000..bc3d531c --- /dev/null +++ b/src/domain/accounts/types/account.ts @@ -0,0 +1,38 @@ +export interface AccountListItem { + id: string | number; + name: string; + number: string; + industry: string; + website: string; + primaryContact: string; + phoneNumber: string; + owner: string; +} + +export interface Account { + id?: string | number; + AccountName?: string; + AccountNumber?: string; + Industry?: string; + Website?: string; + Notes?: string; + Address1?: string; + Address2?: string; + City?: string; + State?: string; + Zip?: string; + Country?: string; +} + +export interface AccountsListParams { + search?: string; + page?: number; + pageSize?: number; +} + +export interface AccountsListResult { + items: AccountListItem[]; + totalCount: number; + page: number; + pageSize: number; +} diff --git a/src/domain/accounts/use-cases/use-account-detail.ts b/src/domain/accounts/use-cases/use-account-detail.ts new file mode 100644 index 00000000..16da6e86 --- /dev/null +++ b/src/domain/accounts/use-cases/use-account-detail.ts @@ -0,0 +1,13 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { accountsApi } from "@/domain/accounts/api/accounts-api"; +import type { Account } from "@/domain/accounts/types/account"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { requireQueryParam } from "@/lib/query/require-query-param"; + +export function useAccountDetail(id: string | undefined): UseQueryResult { + return useQuery({ + queryKey: queryKeys.accounts.detail(id ?? ""), + queryFn: () => accountsApi.getById(requireQueryParam(id, "Account id")), + enabled: Boolean(id), + }); +} diff --git a/src/domain/accounts/use-cases/use-accounts-list.ts b/src/domain/accounts/use-cases/use-accounts-list.ts new file mode 100644 index 00000000..9dcb347a --- /dev/null +++ b/src/domain/accounts/use-cases/use-accounts-list.ts @@ -0,0 +1,14 @@ +import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { accountsApi } from "@/domain/accounts/api/accounts-api"; +import type { AccountsListParams, AccountsListResult } from "@/domain/accounts/types/account"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useAccountsList( + params: AccountsListParams, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.accounts.list(params as Record), + queryFn: () => accountsApi.getList(params), + placeholderData: keepPreviousData, + }); +} diff --git a/src/domain/accounts/use-cases/use-create-account.ts b/src/domain/accounts/use-cases/use-create-account.ts new file mode 100644 index 00000000..1472dfb2 --- /dev/null +++ b/src/domain/accounts/use-cases/use-create-account.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 { accountsApi } from "@/domain/accounts/api/accounts-api"; +import type { AccountFormValues } from "@/domain/accounts/schemas/account-schema"; +import type { Account } from "@/domain/accounts/types/account"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useCreateAccount(): UseMutationResult { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (values: AccountFormValues) => accountsApi.create(values), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.accounts.all }); + toast.success("Account created successfully!"); + navigate("/accounts"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to create account"); + }, + }); +} diff --git a/src/domain/accounts/use-cases/use-delete-account.ts b/src/domain/accounts/use-cases/use-delete-account.ts new file mode 100644 index 00000000..ad2913bb --- /dev/null +++ b/src/domain/accounts/use-cases/use-delete-account.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { accountsApi } from "@/domain/accounts/api/accounts-api"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDeleteAccount(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string | number) => accountsApi.delete(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.accounts.all }); + toast.success("Account deleted successfully!"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to delete account"); + }, + }); +} diff --git a/src/domain/accounts/use-cases/use-update-account.ts b/src/domain/accounts/use-cases/use-update-account.ts new file mode 100644 index 00000000..4e4ff842 --- /dev/null +++ b/src/domain/accounts/use-cases/use-update-account.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 { accountsApi } from "@/domain/accounts/api/accounts-api"; +import type { AccountFormValues } from "@/domain/accounts/schemas/account-schema"; +import type { Account } from "@/domain/accounts/types/account"; +import { queryKeys } from "@/infra/query-key/query-key"; + +interface UpdateAccountInput { + id: string | number; + values: AccountFormValues; +} + +export function useUpdateAccount(): UseMutationResult { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, values }: UpdateAccountInput) => accountsApi.update(id, values), + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ queryKey: queryKeys.accounts.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.accounts.detail(variables.id) }); + toast.success("Account updated successfully!"); + navigate("/accounts"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update account"); + }, + }); +}