mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 07:42:04 +00:00
feat: add locations CRUD module
This commit is contained in:
parent
a60da31f4c
commit
f7adadffbe
13 changed files with 822 additions and 0 deletions
267
src/app/(protected)/locations/_components/location-form.tsx
Normal file
267
src/app/(protected)/locations/_components/location-form.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||||
|
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
|
||||||
|
import {
|
||||||
|
locationDefaultValues,
|
||||||
|
locationSchema,
|
||||||
|
type LocationFormValues,
|
||||||
|
} from "@/domain/locations/schemas/location-schema";
|
||||||
|
import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
||||||
|
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export default function LocationFormPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const [accountSearch, setAccountSearch] = useState("");
|
||||||
|
const debouncedAccountSearch = useDebounce(accountSearch, 300);
|
||||||
|
|
||||||
|
const { data: location, isLoading, error } = useLocationDetail(id);
|
||||||
|
const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({
|
||||||
|
search: debouncedAccountSearch,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
const createLocation = useCreateLocation();
|
||||||
|
const updateLocation = useUpdateLocation();
|
||||||
|
|
||||||
|
const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LocationFormValues>({
|
||||||
|
resolver: zodResolver(locationSchema),
|
||||||
|
defaultValues: locationDefaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location && isEdit) {
|
||||||
|
reset({
|
||||||
|
name: location.name ?? "",
|
||||||
|
accountId: location.accountId != null ? String(location.accountId) : "",
|
||||||
|
address: location.address ?? "",
|
||||||
|
city: location.city ?? "",
|
||||||
|
state: (location.state as LocationFormValues["state"]) || "IL",
|
||||||
|
zipCode: location.zipCode ?? "",
|
||||||
|
phone: location.phone ?? "",
|
||||||
|
contact: location.contact ?? "",
|
||||||
|
contactEmail: location.contactEmail ?? "",
|
||||||
|
status: (location.status as LocationFormValues["status"]) || "Active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [location, isEdit, reset]);
|
||||||
|
|
||||||
|
const isSaving = createLocation.isPending || updateLocation.isPending;
|
||||||
|
|
||||||
|
const onSubmit = (values: LocationFormValues) => {
|
||||||
|
if (isEdit && id) {
|
||||||
|
updateLocation.mutate({ id, values });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createLocation.mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoading) {
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center p-10">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="mx-auto max-w-4xl p-4">
|
||||||
|
<Paper variant="outlined" className="p-6">
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
{isEdit ? "Edit Location" : "Create a Location"}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="text" onClick={() => navigate("/locations")}>
|
||||||
|
Back to list
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load location"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
Location Information
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("name")}
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="accountId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Autocomplete
|
||||||
|
options={accountOptions}
|
||||||
|
loading={isLoadingAccounts}
|
||||||
|
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
String(option.id) === String(value.id)
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
accountOptions.find((account) => String(account.id) === field.value) ?? null
|
||||||
|
}
|
||||||
|
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||||
|
onInputChange={(_event, value, reason) => {
|
||||||
|
if (reason === "input") {
|
||||||
|
setAccountSearch(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Account" placeholder="Search accounts..." />
|
||||||
|
)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
{...register("address")}
|
||||||
|
label="Address"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.address)}
|
||||||
|
helperText={errors.address?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("city")}
|
||||||
|
label="City"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.city)}
|
||||||
|
helperText={errors.city?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="state"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth required error={Boolean(errors.state)}>
|
||||||
|
<InputLabel id="state-label">State</InputLabel>
|
||||||
|
<Select {...field} labelId="state-label" label="State" value={field.value}>
|
||||||
|
{LOCATION_STATE_OPTIONS.map((state) => (
|
||||||
|
<MenuItem key={state} value={state}>
|
||||||
|
{state}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("zipCode")}
|
||||||
|
label="Zip Code"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.zipCode)}
|
||||||
|
helperText={errors.zipCode?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
Contact Information
|
||||||
|
</Typography>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("phone")}
|
||||||
|
label="Phone"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.phone)}
|
||||||
|
helperText={errors.phone?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("contact")}
|
||||||
|
label="Contact Person"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.contact)}
|
||||||
|
helperText={errors.contact?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("contactEmail")}
|
||||||
|
label="Contact Email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.contactEmail)}
|
||||||
|
helperText={errors.contactEmail?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="status"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth required error={Boolean(errors.status)}>
|
||||||
|
<InputLabel id="status-label">Status</InputLabel>
|
||||||
|
<Select {...field} labelId="status-label" label="Status" value={field.value}>
|
||||||
|
{LOCATION_STATUS_OPTIONS.map((status) => (
|
||||||
|
<MenuItem key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="flex-end" spacing={2}>
|
||||||
|
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : isEdit ? "Update Location" : "Create Location"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/locations/edit/[id].tsx
Normal file
1
src/app/(protected)/locations/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/location-form";
|
||||||
221
src/app/(protected)/locations/index.tsx
Normal file
221
src/app/(protected)/locations/index.tsx
Normal file
|
|
@ -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 { LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
|
||||||
|
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export default function LocationsListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deleteLocation = useDeleteLocation();
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useLocationsList(listParams);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLocation.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">
|
||||||
|
Locations
|
||||||
|
</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 locations..."
|
||||||
|
className="min-w-[220px]"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={() => navigate("/locations/new")}>
|
||||||
|
+ New Location
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load locations"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Location Name</TableCell>
|
||||||
|
<TableCell className="font-semibold">Address</TableCell>
|
||||||
|
<TableCell className="font-semibold">City</TableCell>
|
||||||
|
<TableCell className="font-semibold">State</TableCell>
|
||||||
|
<TableCell className="font-semibold">Phone</TableCell>
|
||||||
|
<TableCell className="font-semibold">Contact</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Actions
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<Typography color="text.secondary">No locations found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/locations/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.name || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.address || "—"}</TableCell>
|
||||||
|
<TableCell>{row.city || "—"}</TableCell>
|
||||||
|
<TableCell>{row.state || "—"}</TableCell>
|
||||||
|
<TableCell>{row.phone || "—"}</TableCell>
|
||||||
|
<TableCell>{row.contact || "—"}</TableCell>
|
||||||
|
<TableCell>{row.status || "—"}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Edit location"
|
||||||
|
onClick={() => navigate(`/locations/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Delete location"
|
||||||
|
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 Location</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={deleteLocation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteLocation.isPending}
|
||||||
|
>
|
||||||
|
{deleteLocation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/locations/new.tsx
Normal file
1
src/app/(protected)/locations/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/location-form";
|
||||||
61
src/domain/locations/api/locations-api.ts
Normal file
61
src/domain/locations/api/locations-api.ts
Normal file
|
|
@ -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 {
|
||||||
|
mapLocation,
|
||||||
|
mapLocationListItems,
|
||||||
|
mapLocationToBackend,
|
||||||
|
} from "@/domain/locations/mappers/location-mapper";
|
||||||
|
import type {
|
||||||
|
Location,
|
||||||
|
LocationsListParams,
|
||||||
|
LocationsListResult,
|
||||||
|
} from "@/domain/locations/types/location";
|
||||||
|
|
||||||
|
function buildSearchParams(params: LocationsListParams): Record<string, string | number> {
|
||||||
|
return {
|
||||||
|
search: params.search ?? "",
|
||||||
|
page: params.page ?? 1,
|
||||||
|
pageSize: params.pageSize ?? 12,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const locationsApi = {
|
||||||
|
getList: async (params: LocationsListParams = {}): Promise<LocationsListResult> => {
|
||||||
|
const searchParams = buildSearchParams(params);
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.location.getList, { searchParams });
|
||||||
|
const paginated = handlePaginatedResponse<unknown>(data, {
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: mapLocationListItems(paginated.items),
|
||||||
|
totalCount: paginated.totalCount,
|
||||||
|
page: paginated.page,
|
||||||
|
pageSize: paginated.pageSize,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string | number): Promise<Location> => {
|
||||||
|
const data = await apiGet<unknown>(`${API_PATHS.rest.locations}/${id}`);
|
||||||
|
return mapLocation(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (payload: unknown): Promise<Location> => {
|
||||||
|
const data = await apiPost<unknown>(API_PATHS.rest.locations, mapLocationToBackend(payload));
|
||||||
|
return mapLocation(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string | number, payload: unknown): Promise<Location> => {
|
||||||
|
const data = await apiPut<unknown>(
|
||||||
|
`${API_PATHS.rest.locations}/${id}`,
|
||||||
|
mapLocationToBackend(payload),
|
||||||
|
);
|
||||||
|
return mapLocation(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string | number): Promise<void> => {
|
||||||
|
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
97
src/domain/locations/mappers/location-mapper.ts
Normal file
97
src/domain/locations/mappers/location-mapper.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import type { Location, LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readId(record: Record<string, unknown>): string | number {
|
||||||
|
const value = record.id ?? record.Id;
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalId(record: Record<string, unknown>): string | number {
|
||||||
|
const value = record.accountId ?? record.AccountId;
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLocationListItem(raw: unknown): LocationListItem {
|
||||||
|
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
name: readString(item, "name", "Name"),
|
||||||
|
address: readString(item, "address", "Address"),
|
||||||
|
city: readString(item, "city", "City"),
|
||||||
|
state: readString(item, "state", "State"),
|
||||||
|
zipCode: readString(item, "zipCode", "ZipCode", "Zip"),
|
||||||
|
phone: readString(item, "phone", "Phone"),
|
||||||
|
contact: readString(item, "contact", "Contact", "contactPerson", "ContactPerson"),
|
||||||
|
contactEmail: readString(item, "contactEmail", "ContactEmail"),
|
||||||
|
status: readString(item, "status", "Status"),
|
||||||
|
accountId: readOptionalId(item),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLocationListItems(rawItems: unknown[]): LocationListItem[] {
|
||||||
|
return rawItems.map(mapLocationListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLocation(raw: unknown): Location {
|
||||||
|
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item) || undefined,
|
||||||
|
name: readString(item, "name", "Name") || undefined,
|
||||||
|
address: readString(item, "address", "Address") || undefined,
|
||||||
|
city: readString(item, "city", "City") || undefined,
|
||||||
|
state: readString(item, "state", "State") || undefined,
|
||||||
|
zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined,
|
||||||
|
phone: readString(item, "phone", "Phone") || undefined,
|
||||||
|
contact: readString(item, "contact", "Contact") || undefined,
|
||||||
|
contactEmail: readString(item, "contactEmail", "ContactEmail") || undefined,
|
||||||
|
status: readString(item, "status", "Status") || undefined,
|
||||||
|
accountId: readOptionalId(item) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLocationToBackend(location: unknown): Record<string, unknown> {
|
||||||
|
const item = (typeof location === "object" && location !== null ? location : {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: item.name ?? "",
|
||||||
|
address: item.address ?? "",
|
||||||
|
city: item.city ?? "",
|
||||||
|
state: item.state ?? "",
|
||||||
|
zipCode: item.zipCode ?? "",
|
||||||
|
phone: item.phone ?? "",
|
||||||
|
contact: item.contact ?? "",
|
||||||
|
contactEmail: item.contactEmail ?? "",
|
||||||
|
status: item.status ?? "Active",
|
||||||
|
accountId: item.accountId ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = item.id ?? item.Id;
|
||||||
|
if (typeof id === "string" || typeof id === "number") {
|
||||||
|
payload.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
30
src/domain/locations/schemas/location-schema.ts
Normal file
30
src/domain/locations/schemas/location-schema.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
||||||
|
|
||||||
|
export const locationSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
accountId: z.string().optional(),
|
||||||
|
address: z.string().min(1, "Address is required"),
|
||||||
|
city: z.string().min(1, "City is required"),
|
||||||
|
state: z.enum(LOCATION_STATE_OPTIONS, { message: "State is required" }),
|
||||||
|
zipCode: z.string().min(1, "Zip code is required"),
|
||||||
|
phone: z.string().min(1, "Phone is required"),
|
||||||
|
contact: z.string().min(1, "Contact person is required"),
|
||||||
|
contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"),
|
||||||
|
status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LocationFormValues = z.infer<typeof locationSchema>;
|
||||||
|
|
||||||
|
export const locationDefaultValues: LocationFormValues = {
|
||||||
|
name: "",
|
||||||
|
accountId: "",
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "IL",
|
||||||
|
zipCode: "",
|
||||||
|
phone: "",
|
||||||
|
contact: "",
|
||||||
|
contactEmail: "",
|
||||||
|
status: "Active",
|
||||||
|
};
|
||||||
44
src/domain/locations/types/location.ts
Normal file
44
src/domain/locations/types/location.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
export interface LocationListItem {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zipCode: string;
|
||||||
|
phone: string;
|
||||||
|
contact: string;
|
||||||
|
contactEmail: string;
|
||||||
|
status: string;
|
||||||
|
accountId: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zipCode?: string;
|
||||||
|
phone?: string;
|
||||||
|
contact?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
status?: string;
|
||||||
|
accountId?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationsListParams {
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationsListResult {
|
||||||
|
items: LocationListItem[];
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCATION_STATE_OPTIONS = ["IL", "IN", "MI", "OH", "WI"] as const;
|
||||||
|
|
||||||
|
export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const;
|
||||||
24
src/domain/locations/use-cases/use-create-location.ts
Normal file
24
src/domain/locations/use-cases/use-create-location.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 { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
|
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
export function useCreateLocation(): UseMutationResult<Location, Error, LocationFormValues> {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (values: LocationFormValues) => locationsApi.create(values),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||||
|
toast.success("Location created successfully!");
|
||||||
|
navigate("/locations");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to create location");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
19
src/domain/locations/use-cases/use-delete-location.ts
Normal file
19
src/domain/locations/use-cases/use-delete-location.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
export function useDeleteLocation(): UseMutationResult<void, Error, string | number> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string | number) => locationsApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||||
|
toast.success("Location deleted successfully!");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to delete location");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
13
src/domain/locations/use-cases/use-location-detail.ts
Normal file
13
src/domain/locations/use-cases/use-location-detail.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||||
|
|
||||||
|
export function useLocationDetail(id: string | undefined): UseQueryResult<Location, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.locations.detail(id ?? ""),
|
||||||
|
queryFn: () => locationsApi.getById(requireQueryParam(id, "Location id")),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/domain/locations/use-cases/use-locations-list.ts
Normal file
14
src/domain/locations/use-cases/use-locations-list.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
|
import type { LocationsListParams, LocationsListResult } from "@/domain/locations/types/location";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
export function useLocationsList(
|
||||||
|
params: LocationsListParams,
|
||||||
|
): UseQueryResult<LocationsListResult, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.locations.list(params as Record<string, unknown>),
|
||||||
|
queryFn: () => locationsApi.getList(params),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
30
src/domain/locations/use-cases/use-update-location.ts
Normal file
30
src/domain/locations/use-cases/use-update-location.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 { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
|
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
interface UpdateLocationInput {
|
||||||
|
id: string | number;
|
||||||
|
values: LocationFormValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLocation(): UseMutationResult<Location, Error, UpdateLocationInput> {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, values }: UpdateLocationInput) => locationsApi.update(id, values),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(variables.id) });
|
||||||
|
toast.success("Location updated successfully!");
|
||||||
|
navigate("/locations");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to update location");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue