mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 03:02:03 +00:00
feat: add assets CRUD module
This commit is contained in:
parent
24b123225c
commit
8cf6aa0902
14 changed files with 779 additions and 0 deletions
212
src/app/(protected)/assets/_components/asset-form.tsx
Normal file
212
src/app/(protected)/assets/_components/asset-form.tsx
Normal file
|
|
@ -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<AssetFormValues>({
|
||||
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 (
|
||||
<Box className="flex justify-center p-10">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="mx-auto max-w-3xl p-4">
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography>
|
||||
|
||||
{(error || optionsError) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: optionsError instanceof Error
|
||||
? optionsError.message
|
||||
: "Failed to load asset"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="Asset Code"
|
||||
required
|
||||
fullWidth
|
||||
error={Boolean(errors.assetCode)}
|
||||
helperText={errors.assetCode?.message}
|
||||
{...register("assetCode")}
|
||||
/>
|
||||
<TextField
|
||||
label="Name"
|
||||
required
|
||||
fullWidth
|
||||
error={Boolean(errors.name)}
|
||||
helperText={errors.name?.message}
|
||||
{...register("name")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label="Description"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="accountId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Account</InputLabel>
|
||||
<Select
|
||||
label="Account"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => {
|
||||
const value = String(event.target.value);
|
||||
field.onChange(value === "" ? null : Number(value));
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{accounts.map((account) => (
|
||||
<MenuItem key={String(account.id)} value={account.id}>
|
||||
{account.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="locationId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Location</InputLabel>
|
||||
<Select
|
||||
label="Location"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => {
|
||||
const value = String(event.target.value);
|
||||
field.onChange(value === "" ? null : Number(value));
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{sites.map((site) => (
|
||||
<MenuItem key={String(site.id)} value={site.id}>
|
||||
{site.title ? `${site.title} - ${site.name}` : site.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="isActive"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={field.onChange} />}
|
||||
label="Active"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button variant="outlined" onClick={() => navigate("/assets")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/assets/edit/[id].tsx
Normal file
1
src/app/(protected)/assets/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/asset-form";
|
||||
223
src/app/(protected)/assets/index.tsx
Normal file
223
src/app/(protected)/assets/index.tsx
Normal file
|
|
@ -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<AssetListItem | null>(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 (
|
||||
<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">
|
||||
Assets
|
||||
</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={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search assets..."
|
||||
className="min-w-sidebar"
|
||||
/>
|
||||
<Button variant="contained" onClick={() => navigate("/assets/new")}>
|
||||
+ New Asset
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load assets"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Asset Code</TableCell>
|
||||
<TableCell className="font-semibold">Name</TableCell>
|
||||
<TableCell className="font-semibold">Description</TableCell>
|
||||
<TableCell className="font-semibold">Account</TableCell>
|
||||
<TableCell className="font-semibold">Location</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 assets found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableCell>{row.assetCode || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={() => navigate(`/assets/edit/${row.id}`)}
|
||||
>
|
||||
{row.name || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.description || "—"}</TableCell>
|
||||
<TableCell>{row.accountName || "—"}</TableCell>
|
||||
<TableCell>{row.locationName || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={row.isActive ? "Active" : "Inactive"}
|
||||
color={row.isActive ? "success" : "default"}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Edit asset"
|
||||
onClick={() => navigate(`/assets/edit/${row.id}`)}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Delete asset"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(event) => {
|
||||
setPageSize(Number(event.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Asset</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be
|
||||
undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteAsset.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteAsset.isPending}
|
||||
>
|
||||
{deleteAsset.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/assets/new.tsx
Normal file
1
src/app/(protected)/assets/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/asset-form";
|
||||
75
src/domain/assets/api/assets-api.ts
Normal file
75
src/domain/assets/api/assets-api.ts
Normal file
|
|
@ -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<AssetsListResult> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.asset.getList, {
|
||||
searchParams: {
|
||||
search: params.search ?? "",
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 12,
|
||||
},
|
||||
});
|
||||
|
||||
const paginated = handlePaginatedResponse<unknown>(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<Asset> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.asset.getAssetById, {
|
||||
searchParams: { id },
|
||||
});
|
||||
return mapAsset(handleApiResponse(data));
|
||||
},
|
||||
|
||||
create: async (payload: unknown): Promise<Asset> => {
|
||||
const data = await apiPost<unknown>(
|
||||
API_PATHS.asset.addAsset,
|
||||
mapAssetToBackend(payload as Asset),
|
||||
);
|
||||
return mapAsset(handleApiResponse(data));
|
||||
},
|
||||
|
||||
update: async (id: string | number, payload: unknown): Promise<Asset> => {
|
||||
const data = await apiPost<unknown>(
|
||||
API_PATHS.asset.editAsset,
|
||||
mapAssetToBackend({ ...(payload as Asset), id: Number(id) }),
|
||||
);
|
||||
return mapAsset(handleApiResponse(data));
|
||||
},
|
||||
|
||||
delete: async (id: string | number): Promise<void> => {
|
||||
await apiGet<unknown>(API_PATHS.asset.deleteAsset, { searchParams: { id } });
|
||||
},
|
||||
|
||||
getSiteOptions: async (): Promise<SiteOption[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.common.getSitesName);
|
||||
const items = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(items) ? items.map(mapSiteOption) : [];
|
||||
},
|
||||
};
|
||||
86
src/domain/assets/mappers/asset-mapper.ts
Normal file
86
src/domain/assets/mappers/asset-mapper.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { Asset, AssetListItem, SiteOption } from "@/domain/assets/types/asset";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, ...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<string, unknown>, ...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<Asset> & { 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"),
|
||||
};
|
||||
}
|
||||
12
src/domain/assets/schemas/asset-schema.ts
Normal file
12
src/domain/assets/schemas/asset-schema.ts
Normal file
|
|
@ -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<typeof assetSchema>;
|
||||
39
src/domain/assets/types/asset.ts
Normal file
39
src/domain/assets/types/asset.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
13
src/domain/assets/use-cases/use-asset-detail.ts
Normal file
13
src/domain/assets/use-cases/use-asset-detail.ts
Normal file
|
|
@ -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<Asset, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.assets.detail(id ?? ""),
|
||||
queryFn: () => assetsApi.getById(requireQueryParam(id, "Asset id")),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
32
src/domain/assets/use-cases/use-asset-form-options.ts
Normal file
32
src/domain/assets/use-cases/use-asset-form-options.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
12
src/domain/assets/use-cases/use-assets-list.ts
Normal file
12
src/domain/assets/use-cases/use-assets-list.ts
Normal file
|
|
@ -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<AssetsListResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.assets.list(params as Record<string, unknown>),
|
||||
queryFn: () => assetsApi.getList(params),
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
24
src/domain/assets/use-cases/use-create-asset.ts
Normal file
24
src/domain/assets/use-cases/use-create-asset.ts
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 { 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<Asset, Error, AssetFormValues> {
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
19
src/domain/assets/use-cases/use-delete-asset.ts
Normal file
19
src/domain/assets/use-cases/use-delete-asset.ts
Normal file
|
|
@ -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<void, Error, number> {
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
30
src/domain/assets/use-cases/use-update-asset.ts
Normal file
30
src/domain/assets/use-cases/use-update-asset.ts
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 { 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<Asset, Error, UpdateAssetInput> {
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue