From 8cf6aa0902f0e8c0187b414b5769e939235dc4aa Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 12 Jun 2026 11:53:12 -0300 Subject: [PATCH] feat: add assets CRUD module --- .../assets/_components/asset-form.tsx | 212 +++++++++++++++++ src/app/(protected)/assets/edit/[id].tsx | 1 + src/app/(protected)/assets/index.tsx | 223 ++++++++++++++++++ src/app/(protected)/assets/new.tsx | 1 + src/domain/assets/api/assets-api.ts | 75 ++++++ src/domain/assets/mappers/asset-mapper.ts | 86 +++++++ src/domain/assets/schemas/asset-schema.ts | 12 + src/domain/assets/types/asset.ts | 39 +++ .../assets/use-cases/use-asset-detail.ts | 13 + .../use-cases/use-asset-form-options.ts | 32 +++ .../assets/use-cases/use-assets-list.ts | 12 + .../assets/use-cases/use-create-asset.ts | 24 ++ .../assets/use-cases/use-delete-asset.ts | 19 ++ .../assets/use-cases/use-update-asset.ts | 30 +++ 14 files changed, 779 insertions(+) create mode 100644 src/app/(protected)/assets/_components/asset-form.tsx create mode 100644 src/app/(protected)/assets/edit/[id].tsx create mode 100644 src/app/(protected)/assets/index.tsx create mode 100644 src/app/(protected)/assets/new.tsx create mode 100644 src/domain/assets/api/assets-api.ts create mode 100644 src/domain/assets/mappers/asset-mapper.ts create mode 100644 src/domain/assets/schemas/asset-schema.ts create mode 100644 src/domain/assets/types/asset.ts create mode 100644 src/domain/assets/use-cases/use-asset-detail.ts create mode 100644 src/domain/assets/use-cases/use-asset-form-options.ts create mode 100644 src/domain/assets/use-cases/use-assets-list.ts create mode 100644 src/domain/assets/use-cases/use-create-asset.ts create mode 100644 src/domain/assets/use-cases/use-delete-asset.ts create mode 100644 src/domain/assets/use-cases/use-update-asset.ts diff --git a/src/app/(protected)/assets/_components/asset-form.tsx b/src/app/(protected)/assets/_components/asset-form.tsx new file mode 100644 index 00000000..a3d386e8 --- /dev/null +++ b/src/app/(protected)/assets/_components/asset-form.tsx @@ -0,0 +1,212 @@ +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, + FormControlLabel, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Switch, + TextField, + Typography, +} from "@mui/material"; +import { assetSchema, type AssetFormValues } from "@/domain/assets/schemas/asset-schema"; +import { useAssetDetail } from "@/domain/assets/use-cases/use-asset-detail"; +import { useAssetFormOptions } from "@/domain/assets/use-cases/use-asset-form-options"; +import { useCreateAsset } from "@/domain/assets/use-cases/use-create-asset"; +import { useUpdateAsset } from "@/domain/assets/use-cases/use-update-asset"; + +const defaultValues: AssetFormValues = { + assetCode: "", + name: "", + description: "", + accountId: null, + locationId: null, + isActive: true, +}; + +export default function AssetFormPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const isEdit = Boolean(id); + + const { data: asset, isLoading, error } = useAssetDetail(id); + const { accounts, sites, isLoading: optionsLoading, error: optionsError } = useAssetFormOptions(); + const createAsset = useCreateAsset(); + const updateAsset = useUpdateAsset(); + + const { + register, + handleSubmit, + reset, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(assetSchema), + defaultValues, + }); + + useEffect(() => { + if (asset && isEdit) { + reset({ + assetCode: asset.assetCode, + name: asset.name, + description: asset.description, + accountId: asset.accountId, + locationId: asset.locationId, + isActive: asset.isActive, + }); + } + }, [asset, isEdit, reset]); + + const isSaving = createAsset.isPending || updateAsset.isPending; + + const onSubmit = (values: AssetFormValues) => { + if (isEdit && id) { + updateAsset.mutate({ id, values }); + return; + } + createAsset.mutate(values); + }; + + if ((isEdit && isLoading) || optionsLoading) { + return ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit Asset" : "Create Asset"} + + {(error || optionsError) && ( + + {error instanceof Error + ? error.message + : optionsError instanceof Error + ? optionsError.message + : "Failed to load asset"} + + )} + + + + + + + + + + + ( + + Account + + + )} + /> + + ( + + Location + + + )} + /> + + ( + } + label="Active" + /> + )} + /> + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/assets/edit/[id].tsx b/src/app/(protected)/assets/edit/[id].tsx new file mode 100644 index 00000000..dba59b2a --- /dev/null +++ b/src/app/(protected)/assets/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/asset-form"; diff --git a/src/app/(protected)/assets/index.tsx b/src/app/(protected)/assets/index.tsx new file mode 100644 index 00000000..b59c0b82 --- /dev/null +++ b/src/app/(protected)/assets/index.tsx @@ -0,0 +1,223 @@ +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 { AssetListItem } from "@/domain/assets/types/asset"; +import { useAssetsList } from "@/domain/assets/use-cases/use-assets-list"; +import { useDeleteAsset } from "@/domain/assets/use-cases/use-delete-asset"; +import { useDebounce } from "@/hooks/use-debounce"; + +export default function AssetsListPage() { + 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 deleteAsset = useDeleteAsset(); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + }), + [debouncedSearch, page, pageSize], + ); + + const { data, isLoading, isFetching, error } = useAssetsList(listParams); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + const handleDeleteConfirm = () => { + if (!deleteTarget) return; + deleteAsset.mutate(deleteTarget.id, { + onSuccess: () => setDeleteTarget(null), + }); + }; + + return ( + + + + + Assets + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search assets..." + className="min-w-sidebar" + /> + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load assets"} + + )} + + + + + + Asset Code + Name + Description + Account + Location + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No assets found + + + ) : ( + rows.map((row) => ( + + {row.assetCode || "—"} + + + + {row.description || "—"} + {row.accountName || "—"} + {row.locationName || "—"} + + + + + + navigate(`/assets/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 Asset + + + Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be + undone. + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/assets/new.tsx b/src/app/(protected)/assets/new.tsx new file mode 100644 index 00000000..6feb5f6c --- /dev/null +++ b/src/app/(protected)/assets/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/asset-form"; diff --git a/src/domain/assets/api/assets-api.ts b/src/domain/assets/api/assets-api.ts new file mode 100644 index 00000000..30d77fe1 --- /dev/null +++ b/src/domain/assets/api/assets-api.ts @@ -0,0 +1,75 @@ +import { API_PATHS } from "@/api/api-paths"; +import { apiGet, apiPost } from "@/api/api"; +import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; +import { + mapAsset, + mapAssetListItem, + mapAssetToBackend, + mapSiteOption, +} from "@/domain/assets/mappers/asset-mapper"; +import type { + Asset, + AssetsListParams, + AssetsListResult, + SiteOption, +} from "@/domain/assets/types/asset"; + +export const assetsApi = { + getList: async (params: AssetsListParams = {}): Promise => { + const data = await apiGet(API_PATHS.asset.getList, { + searchParams: { + search: params.search ?? "", + page: params.page ?? 1, + pageSize: params.pageSize ?? 12, + }, + }); + + const paginated = handlePaginatedResponse(data, { + page: params.page, + pageSize: params.pageSize, + }); + + const totalPages = Math.max(1, Math.ceil(paginated.totalCount / paginated.pageSize)); + + return { + items: paginated.items.map(mapAssetListItem), + totalCount: paginated.totalCount, + page: paginated.page, + pageSize: paginated.pageSize, + totalPages, + }; + }, + + getById: async (id: string | number): Promise => { + const data = await apiGet(API_PATHS.asset.getAssetById, { + searchParams: { id }, + }); + return mapAsset(handleApiResponse(data)); + }, + + create: async (payload: unknown): Promise => { + const data = await apiPost( + API_PATHS.asset.addAsset, + mapAssetToBackend(payload as Asset), + ); + return mapAsset(handleApiResponse(data)); + }, + + update: async (id: string | number, payload: unknown): Promise => { + const data = await apiPost( + API_PATHS.asset.editAsset, + mapAssetToBackend({ ...(payload as Asset), id: Number(id) }), + ); + return mapAsset(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiGet(API_PATHS.asset.deleteAsset, { searchParams: { id } }); + }, + + getSiteOptions: async (): Promise => { + const data = await apiGet(API_PATHS.common.getSitesName); + const items = handleApiResponse(data); + return Array.isArray(items) ? items.map(mapSiteOption) : []; + }, +}; diff --git a/src/domain/assets/mappers/asset-mapper.ts b/src/domain/assets/mappers/asset-mapper.ts new file mode 100644 index 00000000..c7cbab38 --- /dev/null +++ b/src/domain/assets/mappers/asset-mapper.ts @@ -0,0 +1,86 @@ +import type { Asset, AssetListItem, SiteOption } from "@/domain/assets/types/asset"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + } + return ""; +} + +function readNumber(record: Record, ...keys: string[]): number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number") { + return value; + } + } + return null; +} + +function readBoolean(record: Record, ...keys: string[]): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + } + return true; +} + +export function mapAssetListItem(raw: unknown): AssetListItem { + const record = isRecord(raw) ? raw : {}; + + return { + id: readNumber(record, "id", "Id") ?? 0, + assetCode: readString(record, "assetCode", "AssetCode"), + name: readString(record, "name", "Name"), + description: readString(record, "description", "Description"), + isActive: readBoolean(record, "isActive", "IsActive"), + accountName: readString(record, "accountName", "AccountName"), + locationName: readString(record, "locationName", "LocationName"), + }; +} + +export function mapAsset(raw: unknown): Asset { + const record = isRecord(raw) ? raw : {}; + const listItem = mapAssetListItem(raw); + + return { + id: listItem.id, + assetCode: listItem.assetCode, + name: listItem.name, + description: listItem.description, + accountId: readNumber(record, "accountId", "AccountId"), + locationId: readNumber(record, "locationId", "LocationId"), + isActive: listItem.isActive, + }; +} + +export function mapAssetToBackend(asset: Partial & { id?: number }) { + return { + ...(asset.id ? { id: asset.id } : {}), + assetCode: asset.assetCode ?? "", + name: asset.name ?? "", + description: asset.description || null, + accountId: asset.accountId ?? null, + locationId: asset.locationId ?? null, + isActive: asset.isActive ?? true, + }; +} + +export function mapSiteOption(raw: unknown): SiteOption { + const record = isRecord(raw) ? raw : {}; + + return { + id: readNumber(record, "id", "Id") ?? 0, + title: readString(record, "title", "Title"), + name: readString(record, "name", "Name"), + }; +} diff --git a/src/domain/assets/schemas/asset-schema.ts b/src/domain/assets/schemas/asset-schema.ts new file mode 100644 index 00000000..2cba9414 --- /dev/null +++ b/src/domain/assets/schemas/asset-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const assetSchema = z.object({ + assetCode: z.string().min(1, "Asset code is required"), + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + accountId: z.number().nullable().optional(), + locationId: z.number().nullable().optional(), + isActive: z.boolean(), +}); + +export type AssetFormValues = z.infer; diff --git a/src/domain/assets/types/asset.ts b/src/domain/assets/types/asset.ts new file mode 100644 index 00000000..ea3d64cc --- /dev/null +++ b/src/domain/assets/types/asset.ts @@ -0,0 +1,39 @@ +export interface AssetListItem { + id: number; + assetCode: string; + name: string; + description: string; + isActive: boolean; + accountName: string; + locationName: string; +} + +export interface Asset { + id: number; + assetCode: string; + name: string; + description: string; + accountId: number | null; + locationId: number | null; + isActive: boolean; +} + +export interface AssetsListParams { + search?: string; + page?: number; + pageSize?: number; +} + +export interface AssetsListResult { + items: AssetListItem[]; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface SiteOption { + id: number; + title: string; + name: string; +} diff --git a/src/domain/assets/use-cases/use-asset-detail.ts b/src/domain/assets/use-cases/use-asset-detail.ts new file mode 100644 index 00000000..e9395059 --- /dev/null +++ b/src/domain/assets/use-cases/use-asset-detail.ts @@ -0,0 +1,13 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { assetsApi } from "@/domain/assets/api/assets-api"; +import type { Asset } from "@/domain/assets/types/asset"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { requireQueryParam } from "@/lib/query/require-query-param"; + +export function useAssetDetail(id?: string): UseQueryResult { + return useQuery({ + queryKey: queryKeys.assets.detail(id ?? ""), + queryFn: () => assetsApi.getById(requireQueryParam(id, "Asset id")), + enabled: Boolean(id), + }); +} diff --git a/src/domain/assets/use-cases/use-asset-form-options.ts b/src/domain/assets/use-cases/use-asset-form-options.ts new file mode 100644 index 00000000..414ef968 --- /dev/null +++ b/src/domain/assets/use-cases/use-asset-form-options.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import { accountsApi } from "@/domain/accounts/api/accounts-api"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import { assetsApi } from "@/domain/assets/api/assets-api"; +import type { SiteOption } from "@/domain/assets/types/asset"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export interface AssetFormOptionsResult { + accounts: AccountListItem[]; + sites: SiteOption[]; + isLoading: boolean; + error: Error | null; +} + +export function useAssetFormOptions(): AssetFormOptionsResult { + const accountsQuery = useQuery({ + queryKey: queryKeys.accounts.list({ page: 1, pageSize: 1000 }), + queryFn: () => accountsApi.getList({ page: 1, pageSize: 1000 }), + }); + + const sitesQuery = useQuery({ + queryKey: queryKeys.assets.sites(), + queryFn: () => assetsApi.getSiteOptions(), + }); + + return { + accounts: accountsQuery.data?.items ?? [], + sites: sitesQuery.data ?? [], + isLoading: accountsQuery.isLoading || sitesQuery.isLoading, + error: accountsQuery.error ?? sitesQuery.error, + }; +} diff --git a/src/domain/assets/use-cases/use-assets-list.ts b/src/domain/assets/use-cases/use-assets-list.ts new file mode 100644 index 00000000..6d8d58aa --- /dev/null +++ b/src/domain/assets/use-cases/use-assets-list.ts @@ -0,0 +1,12 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { assetsApi } from "@/domain/assets/api/assets-api"; +import type { AssetsListParams, AssetsListResult } from "@/domain/assets/types/asset"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useAssetsList(params: AssetsListParams): UseQueryResult { + return useQuery({ + queryKey: queryKeys.assets.list(params as Record), + queryFn: () => assetsApi.getList(params), + placeholderData: (previous) => previous, + }); +} diff --git a/src/domain/assets/use-cases/use-create-asset.ts b/src/domain/assets/use-cases/use-create-asset.ts new file mode 100644 index 00000000..0afa5121 --- /dev/null +++ b/src/domain/assets/use-cases/use-create-asset.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 { assetsApi } from "@/domain/assets/api/assets-api"; +import type { AssetFormValues } from "@/domain/assets/schemas/asset-schema"; +import type { Asset } from "@/domain/assets/types/asset"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useCreateAsset(): UseMutationResult { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (values: AssetFormValues) => assetsApi.create(values), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.assets.all }); + toast.success("Asset created successfully!"); + navigate("/assets"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to create asset"); + }, + }); +} diff --git a/src/domain/assets/use-cases/use-delete-asset.ts b/src/domain/assets/use-cases/use-delete-asset.ts new file mode 100644 index 00000000..3eaf6257 --- /dev/null +++ b/src/domain/assets/use-cases/use-delete-asset.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { assetsApi } from "@/domain/assets/api/assets-api"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDeleteAsset(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => assetsApi.delete(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.assets.all }); + toast.success("Asset deleted successfully!"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to delete asset"); + }, + }); +} diff --git a/src/domain/assets/use-cases/use-update-asset.ts b/src/domain/assets/use-cases/use-update-asset.ts new file mode 100644 index 00000000..9c0fb5bb --- /dev/null +++ b/src/domain/assets/use-cases/use-update-asset.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 { assetsApi } from "@/domain/assets/api/assets-api"; +import type { AssetFormValues } from "@/domain/assets/schemas/asset-schema"; +import type { Asset } from "@/domain/assets/types/asset"; +import { queryKeys } from "@/infra/query-key/query-key"; + +interface UpdateAssetInput { + id: string; + values: AssetFormValues; +} + +export function useUpdateAsset(): UseMutationResult { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, values }: UpdateAssetInput) => assetsApi.update(id, values), + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ queryKey: queryKeys.assets.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.assets.detail(variables.id) }); + toast.success("Asset updated successfully!"); + navigate("/assets"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update asset"); + }, + }); +}