diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx new file mode 100644 index 00000000..03f996cc --- /dev/null +++ b/src/app/(protected)/locations/_components/location-form.tsx @@ -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({ + 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 ( + + + + ); + } + + return ( + + + + + + {isEdit ? "Edit Location" : "Create a Location"} + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load location"} + + )} + + + + + Location Information + + + + ( + 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) => ( + + )} + className="flex-1" + /> + )} + /> + + + + + ( + + State + + + )} + /> + + + + + + + Contact Information + + + + + + + + ( + + Status + + + )} + /> + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/edit/[id].tsx b/src/app/(protected)/locations/edit/[id].tsx new file mode 100644 index 00000000..f25ea225 --- /dev/null +++ b/src/app/(protected)/locations/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/location-form"; diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx new file mode 100644 index 00000000..b9a43000 --- /dev/null +++ b/src/app/(protected)/locations/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 { 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(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 ( + + + + + Locations + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search locations..." + className="min-w-[220px]" + /> + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load locations"} + + )} + + + + + + Location Name + Address + City + State + Phone + Contact + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No locations found + + + ) : ( + rows.map((row) => ( + + + + + {row.address || "—"} + {row.city || "—"} + {row.state || "—"} + {row.phone || "—"} + {row.contact || "—"} + {row.status || "—"} + + + navigate(`/locations/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 Location + + + Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be + undone. + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/locations/new.tsx b/src/app/(protected)/locations/new.tsx new file mode 100644 index 00000000..da675675 --- /dev/null +++ b/src/app/(protected)/locations/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/location-form"; diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts new file mode 100644 index 00000000..1aeb60f0 --- /dev/null +++ b/src/domain/locations/api/locations-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 { + mapLocation, + mapLocationListItems, + mapLocationToBackend, +} from "@/domain/locations/mappers/location-mapper"; +import type { + Location, + LocationsListParams, + LocationsListResult, +} from "@/domain/locations/types/location"; + +function buildSearchParams(params: LocationsListParams): Record { + return { + search: params.search ?? "", + page: params.page ?? 1, + pageSize: params.pageSize ?? 12, + }; +} + +export const locationsApi = { + getList: async (params: LocationsListParams = {}): Promise => { + const searchParams = buildSearchParams(params); + const data = await apiGet(API_PATHS.location.getList, { searchParams }); + const paginated = handlePaginatedResponse(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 => { + const data = await apiGet(`${API_PATHS.rest.locations}/${id}`); + return mapLocation(handleApiResponse(data)); + }, + + create: async (payload: unknown): Promise => { + const data = await apiPost(API_PATHS.rest.locations, mapLocationToBackend(payload)); + return mapLocation(handleApiResponse(data)); + }, + + update: async (id: string | number, payload: unknown): Promise => { + const data = await apiPut( + `${API_PATHS.rest.locations}/${id}`, + mapLocationToBackend(payload), + ); + return mapLocation(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiDelete(`${API_PATHS.rest.locations}/${id}`); + }, +}; diff --git a/src/domain/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts new file mode 100644 index 00000000..dd24deb7 --- /dev/null +++ b/src/domain/locations/mappers/location-mapper.ts @@ -0,0 +1,97 @@ +import type { Location, LocationListItem } from "@/domain/locations/types/location"; + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + return ""; +} + +function readId(record: Record): string | number { + const value = record.id ?? record.Id; + if (typeof value === "string" || typeof value === "number") { + return value; + } + return ""; +} + +function readOptionalId(record: Record): 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; + + 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; + + 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 { + const item = (typeof location === "object" && location !== null ? location : {}) as Record< + string, + unknown + >; + + const payload: Record = { + 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; +} diff --git a/src/domain/locations/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts new file mode 100644 index 00000000..b516ff8a --- /dev/null +++ b/src/domain/locations/schemas/location-schema.ts @@ -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; + +export const locationDefaultValues: LocationFormValues = { + name: "", + accountId: "", + address: "", + city: "", + state: "IL", + zipCode: "", + phone: "", + contact: "", + contactEmail: "", + status: "Active", +}; diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts new file mode 100644 index 00000000..18828d4f --- /dev/null +++ b/src/domain/locations/types/location.ts @@ -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; diff --git a/src/domain/locations/use-cases/use-create-location.ts b/src/domain/locations/use-cases/use-create-location.ts new file mode 100644 index 00000000..08a66a6b --- /dev/null +++ b/src/domain/locations/use-cases/use-create-location.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 { 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 { + 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"); + }, + }); +} diff --git a/src/domain/locations/use-cases/use-delete-location.ts b/src/domain/locations/use-cases/use-delete-location.ts new file mode 100644 index 00000000..4e710337 --- /dev/null +++ b/src/domain/locations/use-cases/use-delete-location.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/locations/use-cases/use-location-detail.ts b/src/domain/locations/use-cases/use-location-detail.ts new file mode 100644 index 00000000..2a5bb45d --- /dev/null +++ b/src/domain/locations/use-cases/use-location-detail.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.locations.detail(id ?? ""), + queryFn: () => locationsApi.getById(requireQueryParam(id, "Location id")), + enabled: Boolean(id), + }); +} diff --git a/src/domain/locations/use-cases/use-locations-list.ts b/src/domain/locations/use-cases/use-locations-list.ts new file mode 100644 index 00000000..5bb16060 --- /dev/null +++ b/src/domain/locations/use-cases/use-locations-list.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.locations.list(params as Record), + queryFn: () => locationsApi.getList(params), + placeholderData: keepPreviousData, + }); +} diff --git a/src/domain/locations/use-cases/use-update-location.ts b/src/domain/locations/use-cases/use-update-location.ts new file mode 100644 index 00000000..97385a87 --- /dev/null +++ b/src/domain/locations/use-cases/use-update-location.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 { 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 { + 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"); + }, + }); +}