diff --git a/src/app/(protected)/contacts/_components/contact-form.tsx b/src/app/(protected)/contacts/_components/contact-form.tsx new file mode 100644 index 00000000..629cb2fc --- /dev/null +++ b/src/app/(protected)/contacts/_components/contact-form.tsx @@ -0,0 +1,259 @@ +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Alert, + Box, + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { contactSchema, type ContactFormValues } from "@/domain/contacts/schemas/contact-schema"; +import { useContactDetail } from "@/domain/contacts/use-cases/use-contact-detail"; +import { useCreateContact } from "@/domain/contacts/use-cases/use-create-contact"; +import { useUpdateContact } from "@/domain/contacts/use-cases/use-update-contact"; + +const defaultValues: ContactFormValues = { + firstName: "", + middleName: "", + lastName: "", + title: "", + contactType: "", + gender: "", + dateOfBirth: "", + territory: "-1", + owner: "1", + privacySetting: "PublicReadWrite", + about: "", + tags: "", + street: "", + streetLineTwo: "", + city: "", + zip: "", + country: "US", + state: "", + county: "", + timeZone: "Eastern Standard Time", + smsAddress: "", + website: "", + facebookUrl: "", + linkedInUrl: "", + twitterUrl: "", +}; + +const CONTACT_TYPES = ["Corporate", "Store Mgr", "Facilities", "Area Leader"]; +const US_STATES = ["AL", "AK", "AZ", "CA", "FL", "NY", "TX"]; + +export default function ContactFormPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const isEdit = Boolean(id); + + const { data: contact, isLoading, error } = useContactDetail(id); + const createContact = useCreateContact(); + const updateContact = useUpdateContact(); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(contactSchema), + defaultValues, + }); + + useEffect(() => { + if (contact && isEdit) { + reset({ + firstName: contact.firstName, + middleName: contact.middleName, + lastName: contact.lastName, + title: contact.title, + contactType: contact.contactType, + gender: contact.gender, + dateOfBirth: contact.dateOfBirth, + territory: contact.territory || "-1", + owner: contact.owner || "1", + privacySetting: contact.privacySetting, + about: contact.about, + tags: contact.tags, + street: contact.street, + streetLineTwo: contact.streetLineTwo, + city: contact.city, + zip: contact.zip, + country: contact.country, + state: contact.state, + county: contact.county, + timeZone: contact.timeZone, + smsAddress: contact.smsAddress, + website: contact.website, + facebookUrl: contact.facebookUrl, + linkedInUrl: contact.linkedInUrl, + twitterUrl: contact.twitterUrl, + }); + } + }, [contact, isEdit, reset]); + + const isSaving = createContact.isPending || updateContact.isPending; + + const onSubmit = (values: ContactFormValues) => { + if (isEdit && id) { + updateContact.mutate({ id, values }); + return; + } + createContact.mutate(values); + }; + + if (isEdit && isLoading) { + return ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit Contact" : "Create Contact"} + + {error && ( + + {error instanceof Error ? error.message : "Failed to load contact"} + + )} + + + + + General Details + + + + + + + + + + + + Contact Type + + + + + + + Territory + + + + Owner + + + + Privacy + + + + + + + + Address + + + + + + + + + + + + State + + + + + + + Additional + + + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/contacts/edit/[id].tsx b/src/app/(protected)/contacts/edit/[id].tsx new file mode 100644 index 00000000..7a4bf5ac --- /dev/null +++ b/src/app/(protected)/contacts/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/contact-form"; diff --git a/src/app/(protected)/contacts/index.tsx b/src/app/(protected)/contacts/index.tsx new file mode 100644 index 00000000..6fb07257 --- /dev/null +++ b/src/app/(protected)/contacts/index.tsx @@ -0,0 +1,200 @@ +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 { ContactListItem } from "@/domain/contacts/types/contact"; +import { useContactsList } from "@/domain/contacts/use-cases/use-contacts-list"; +import { useDeleteContact } from "@/domain/contacts/use-cases/use-delete-contact"; +import { useDebounce } from "@/hooks/use-debounce"; + +export default function ContactsListPage() { + 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 deleteContact = useDeleteContact(); + + const listParams = useMemo( + () => ({ search: debouncedSearch, page: page + 1, pageSize }), + [debouncedSearch, page, pageSize], + ); + + const { data, isLoading, isFetching, error } = useContactsList(listParams); + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + return ( + + + + + Contacts + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(e.target.value); + setPage(0); + }} + placeholder="Search contacts..." + className="min-w-sidebar" + /> + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load contacts"} + + )} + + + + + + Name + Account + Title + Email + Phone + Owner + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No contacts found + + + ) : ( + rows.map((row) => ( + + + + + {row.account || "—"} + {row.title || "—"} + {row.email || "—"} + {row.phoneNumber || "—"} + {row.owner || "—"} + + + navigate(`/contacts/edit/${row.id}`)}> + + + + + setDeleteTarget(row)}> + + + + + + )) + )} + +
+
+ + setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(Number(e.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + + setDeleteTarget(null)}> + Delete Contact + + + Are you sure you want to delete "{deleteTarget?.name}"? + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/contacts/new.tsx b/src/app/(protected)/contacts/new.tsx new file mode 100644 index 00000000..0d47fdbc --- /dev/null +++ b/src/app/(protected)/contacts/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/contact-form"; diff --git a/src/domain/contacts/api/contacts-api.ts b/src/domain/contacts/api/contacts-api.ts new file mode 100644 index 00000000..852254d4 --- /dev/null +++ b/src/domain/contacts/api/contacts-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 { + mapContact, + mapContactListItems, + mapContactToBackend, +} from "@/domain/contacts/mappers/contact-mapper"; +import type { + Contact, + ContactsListParams, + ContactsListResult, +} from "@/domain/contacts/types/contact"; + +function buildListParams(params: ContactsListParams): Record { + return { + search: params.search ?? "", + page: params.page ?? 1, + pageSize: params.pageSize ?? 12, + }; +} + +export const contactsApi = { + getList: async (params: ContactsListParams = {}): Promise => { + const data = await apiGet(API_PATHS.contact.getList, { + searchParams: buildListParams(params), + }); + const paginated = handlePaginatedResponse(data, { + page: params.page, + pageSize: params.pageSize, + }); + return { + items: mapContactListItems(paginated.items), + totalCount: paginated.totalCount, + page: paginated.page, + pageSize: paginated.pageSize, + }; + }, + + getById: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.rest.contacts}/${id}`); + return mapContact(handleApiResponse(data)); + }, + + create: async (payload: unknown): Promise => { + const data = await apiPost(API_PATHS.rest.contacts, mapContactToBackend(payload)); + return mapContact(handleApiResponse(data)); + }, + + update: async (id: string | number, payload: unknown): Promise => { + const data = await apiPut( + `${API_PATHS.rest.contacts}/${id}`, + mapContactToBackend(payload), + ); + return mapContact(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiDelete(`${API_PATHS.rest.contacts}/${id}`); + }, +}; diff --git a/src/domain/contacts/mappers/contact-mapper.ts b/src/domain/contacts/mappers/contact-mapper.ts new file mode 100644 index 00000000..9216ee6a --- /dev/null +++ b/src/domain/contacts/mappers/contact-mapper.ts @@ -0,0 +1,105 @@ +import type { Contact, ContactListItem } from "@/domain/contacts/types/contact"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + } + return ""; +} + +function readId(record: Record): string | number { + const value = record.id ?? record.Id; + if (typeof value === "string" || typeof value === "number") return value; + return ""; +} + +export function mapContactListItem(raw: unknown): ContactListItem { + const item = asRecord(raw); + return { + id: readId(item), + name: readString(item, "name", "Name"), + account: readString(item, "account", "Account"), + title: readString(item, "title", "Title"), + email: readString(item, "email", "Email"), + phoneNumber: readString(item, "phoneNumber", "phone", "Phone"), + owner: readString(item, "owner", "Owner"), + }; +} + +export function mapContactListItems(rawItems: unknown[]): ContactListItem[] { + return rawItems.map(mapContactListItem); +} + +export function mapContact(raw: unknown): Contact { + const item = asRecord(raw); + return { + id: readId(item) || undefined, + firstName: readString(item, "firstName", "FirstName"), + middleName: readString(item, "middleName", "MiddleName"), + lastName: readString(item, "lastName", "LastName"), + title: readString(item, "title", "Title"), + contactType: readString(item, "contactType", "ContactType"), + gender: readString(item, "gender", "Gender"), + dateOfBirth: readString(item, "dateOfBirth", "DateOfBirth").slice(0, 10), + territory: readString(item, "territory", "Territory"), + owner: readString(item, "owner", "Owner"), + privacySetting: readString(item, "privacySetting", "PrivacySetting") || "PublicReadWrite", + about: readString(item, "about", "About"), + tags: readString(item, "tags", "Tags"), + street: readString(item, "street", "Street", "address1", "Address1"), + streetLineTwo: readString(item, "streetLineTwo", "StreetLineTwo", "address2", "Address2"), + city: readString(item, "city", "City"), + zip: readString(item, "zip", "Zip"), + country: readString(item, "country", "Country") || "US", + state: readString(item, "state", "State"), + county: readString(item, "county", "County"), + timeZone: readString(item, "timeZone", "TimeZone") || "Eastern Standard Time", + smsAddress: readString(item, "smsAddress", "SmsAddress"), + website: readString(item, "website", "Website"), + facebookUrl: readString(item, "facebookUrl", "FacebookUrl"), + linkedInUrl: readString(item, "linkedInUrl", "LinkedInUrl"), + twitterUrl: readString(item, "twitterUrl", "TwitterUrl"), + }; +} + +export function mapContactToBackend(contact: unknown): Record { + const item = asRecord(contact); + const payload: Record = { + firstName: item.firstName ?? "", + middleName: item.middleName ?? "", + lastName: item.lastName ?? "", + title: item.title ?? "", + contactType: item.contactType ?? "", + gender: item.gender ?? "", + dateOfBirth: item.dateOfBirth ?? "", + territory: item.territory ?? "", + owner: item.owner ?? "", + privacySetting: item.privacySetting ?? "PublicReadWrite", + about: item.about ?? "", + tags: item.tags ?? "", + street: item.street ?? "", + streetLineTwo: item.streetLineTwo ?? "", + city: item.city ?? "", + zip: item.zip ?? "", + country: item.country ?? "US", + state: item.state ?? "", + county: item.county ?? "", + timeZone: item.timeZone ?? "Eastern Standard Time", + smsAddress: item.smsAddress ?? "", + website: item.website ?? "", + facebookUrl: item.facebookUrl ?? "", + linkedInUrl: item.linkedInUrl ?? "", + twitterUrl: item.twitterUrl ?? "", + }; + const id = item.id ?? item.Id; + if (typeof id === "string" || typeof id === "number") { + payload.id = id; + } + return payload; +} diff --git a/src/domain/contacts/schemas/contact-schema.ts b/src/domain/contacts/schemas/contact-schema.ts new file mode 100644 index 00000000..0873f02a --- /dev/null +++ b/src/domain/contacts/schemas/contact-schema.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const contactSchema = z.object({ + firstName: z.string().min(1, "First name is required").max(50), + middleName: z.string().max(50).optional(), + lastName: z.string().min(1, "Last name is required").max(50), + title: z.string().max(50).optional(), + contactType: z.string().optional(), + gender: z.string().optional(), + dateOfBirth: z.string().optional(), + territory: z.string().min(1, "Territory is required"), + owner: z.string().min(1, "Owner is required"), + privacySetting: z.string().optional(), + about: z.string().optional(), + tags: z.string().optional(), + street: z.string().max(250).optional(), + streetLineTwo: z.string().max(250).optional(), + city: z.string().max(100).optional(), + zip: z.string().max(20).optional(), + country: z.string().optional(), + state: z.string().optional(), + county: z.string().optional(), + timeZone: z.string().optional(), + smsAddress: z.string().optional(), + website: z.string().optional(), + facebookUrl: z.string().optional(), + linkedInUrl: z.string().optional(), + twitterUrl: z.string().optional(), +}); + +export type ContactFormValues = z.infer; diff --git a/src/domain/contacts/types/contact.ts b/src/domain/contacts/types/contact.ts new file mode 100644 index 00000000..5985c44f --- /dev/null +++ b/src/domain/contacts/types/contact.ts @@ -0,0 +1,51 @@ +export interface ContactListItem { + id: string | number; + name: string; + account: string; + title: string; + email: string; + phoneNumber: string; + owner: string; +} + +export interface Contact { + id?: string | number; + firstName: string; + middleName: string; + lastName: string; + title: string; + contactType: string; + gender: string; + dateOfBirth: string; + territory: string; + owner: string; + privacySetting: string; + about: string; + tags: string; + street: string; + streetLineTwo: string; + city: string; + zip: string; + country: string; + state: string; + county: string; + timeZone: string; + smsAddress: string; + website: string; + facebookUrl: string; + linkedInUrl: string; + twitterUrl: string; +} + +export interface ContactsListParams { + search?: string; + page?: number; + pageSize?: number; +} + +export interface ContactsListResult { + items: ContactListItem[]; + totalCount: number; + page: number; + pageSize: number; +} diff --git a/src/domain/contacts/use-cases/use-contact-detail.ts b/src/domain/contacts/use-cases/use-contact-detail.ts new file mode 100644 index 00000000..cd457c30 --- /dev/null +++ b/src/domain/contacts/use-cases/use-contact-detail.ts @@ -0,0 +1,13 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { contactsApi } from "@/domain/contacts/api/contacts-api"; +import type { Contact } from "@/domain/contacts/types/contact"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { requireQueryParam } from "@/lib/query/require-query-param"; + +export function useContactDetail(id: string | undefined): UseQueryResult { + return useQuery({ + queryKey: queryKeys.contacts.detail(id ?? ""), + queryFn: () => contactsApi.getById(requireQueryParam(id, "Contact id")), + enabled: Boolean(id), + }); +} diff --git a/src/domain/contacts/use-cases/use-contacts-list.ts b/src/domain/contacts/use-cases/use-contacts-list.ts new file mode 100644 index 00000000..759a8cd7 --- /dev/null +++ b/src/domain/contacts/use-cases/use-contacts-list.ts @@ -0,0 +1,14 @@ +import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { contactsApi } from "@/domain/contacts/api/contacts-api"; +import type { ContactsListParams, ContactsListResult } from "@/domain/contacts/types/contact"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useContactsList( + params: ContactsListParams, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.contacts.list(params as Record), + queryFn: () => contactsApi.getList(params), + placeholderData: keepPreviousData, + }); +} diff --git a/src/domain/contacts/use-cases/use-create-contact.ts b/src/domain/contacts/use-cases/use-create-contact.ts new file mode 100644 index 00000000..44b84eb1 --- /dev/null +++ b/src/domain/contacts/use-cases/use-create-contact.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 { contactsApi } from "@/domain/contacts/api/contacts-api"; +import type { ContactFormValues } from "@/domain/contacts/schemas/contact-schema"; +import type { Contact } from "@/domain/contacts/types/contact"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useCreateContact(): UseMutationResult { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (values: ContactFormValues) => contactsApi.create(values), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.contacts.all }); + toast.success("Contact created successfully!"); + navigate("/contacts"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to create contact"); + }, + }); +} diff --git a/src/domain/contacts/use-cases/use-delete-contact.ts b/src/domain/contacts/use-cases/use-delete-contact.ts new file mode 100644 index 00000000..c1ac93b3 --- /dev/null +++ b/src/domain/contacts/use-cases/use-delete-contact.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { contactsApi } from "@/domain/contacts/api/contacts-api"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDeleteContact(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string | number) => contactsApi.delete(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.contacts.all }); + toast.success("Contact deleted"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to delete contact"); + }, + }); +} diff --git a/src/domain/contacts/use-cases/use-update-contact.ts b/src/domain/contacts/use-cases/use-update-contact.ts new file mode 100644 index 00000000..6e844935 --- /dev/null +++ b/src/domain/contacts/use-cases/use-update-contact.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 { contactsApi } from "@/domain/contacts/api/contacts-api"; +import type { ContactFormValues } from "@/domain/contacts/schemas/contact-schema"; +import type { Contact } from "@/domain/contacts/types/contact"; +import { queryKeys } from "@/infra/query-key/query-key"; + +interface UpdateContactInput { + id: string | number; + values: ContactFormValues; +} + +export function useUpdateContact(): UseMutationResult { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, values }: UpdateContactInput) => contactsApi.update(id, values), + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ queryKey: queryKeys.contacts.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.contacts.detail(variables.id) }); + toast.success("Contact updated successfully!"); + navigate("/contacts"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update contact"); + }, + }); +}