mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 00:02:06 +00:00
feat: add contacts CRUD module
This commit is contained in:
parent
8cf6aa0902
commit
ef5c73fca3
13 changed files with 809 additions and 0 deletions
259
src/app/(protected)/contacts/_components/contact-form.tsx
Normal file
259
src/app/(protected)/contacts/_components/contact-form.tsx
Normal file
|
|
@ -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<ContactFormValues>({
|
||||
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 (
|
||||
<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}>
|
||||
<Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load contact"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
General Details
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="First Name"
|
||||
required
|
||||
fullWidth
|
||||
error={Boolean(errors.firstName)}
|
||||
helperText={errors.firstName?.message}
|
||||
{...register("firstName")}
|
||||
/>
|
||||
<TextField label="Middle Name" fullWidth {...register("middleName")} />
|
||||
<TextField
|
||||
label="Last Name"
|
||||
required
|
||||
fullWidth
|
||||
error={Boolean(errors.lastName)}
|
||||
helperText={errors.lastName?.message}
|
||||
{...register("lastName")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField label="Title" fullWidth {...register("title")} />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Contact Type</InputLabel>
|
||||
<Select label="Contact Type" defaultValue="" {...register("contactType")}>
|
||||
<MenuItem value="">Select type</MenuItem>
|
||||
{CONTACT_TYPES.map((type) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{type}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Territory</InputLabel>
|
||||
<Select label="Territory" required defaultValue="-1" {...register("territory")}>
|
||||
<MenuItem value="">Select territory</MenuItem>
|
||||
<MenuItem value="-1">No Territory</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Owner</InputLabel>
|
||||
<Select label="Owner" required defaultValue="1" {...register("owner")}>
|
||||
<MenuItem value="">Select owner</MenuItem>
|
||||
<MenuItem value="1">Employee 1</MenuItem>
|
||||
<MenuItem value="2">Employee 2</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Privacy</InputLabel>
|
||||
<Select
|
||||
label="Privacy"
|
||||
defaultValue="PublicReadWrite"
|
||||
{...register("privacySetting")}
|
||||
>
|
||||
<MenuItem value="PublicReadWrite">Public read/write</MenuItem>
|
||||
<MenuItem value="PublicReadOnly">Public read only</MenuItem>
|
||||
<MenuItem value="Private">Private</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<TextField label="About" fullWidth {...register("about")} />
|
||||
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Address
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField label="Address 1" fullWidth {...register("street")} />
|
||||
<TextField label="Address 2" fullWidth {...register("streetLineTwo")} />
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField label="City" fullWidth {...register("city")} />
|
||||
<TextField label="Zip" fullWidth {...register("zip")} />
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>State</InputLabel>
|
||||
<Select label="State" defaultValue="" {...register("state")}>
|
||||
<MenuItem value="">Select state</MenuItem>
|
||||
{US_STATES.map((st) => (
|
||||
<MenuItem key={st} value={st}>
|
||||
{st}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Country" fullWidth {...register("country")} />
|
||||
</Stack>
|
||||
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Additional
|
||||
</Typography>
|
||||
<TextField label="Website" fullWidth placeholder="http://" {...register("website")} />
|
||||
<TextField label="SMS Address" fullWidth {...register("smsAddress")} />
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/contacts")}
|
||||
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)/contacts/edit/[id].tsx
Normal file
1
src/app/(protected)/contacts/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/contact-form";
|
||||
200
src/app/(protected)/contacts/index.tsx
Normal file
200
src/app/(protected)/contacts/index.tsx
Normal file
|
|
@ -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<ContactListItem | null>(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 (
|
||||
<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">
|
||||
Contacts
|
||||
</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={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search contacts..."
|
||||
className="min-w-sidebar"
|
||||
/>
|
||||
<Button variant="contained" onClick={() => navigate("/contacts/new")}>
|
||||
+ New Contact
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load contacts"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Name</TableCell>
|
||||
<TableCell className="font-semibold">Account</TableCell>
|
||||
<TableCell className="font-semibold">Title</TableCell>
|
||||
<TableCell className="font-semibold">Email</TableCell>
|
||||
<TableCell className="font-semibold">Phone</TableCell>
|
||||
<TableCell className="font-semibold">Owner</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 contacts found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={() => navigate(`/contacts/edit/${row.id}`)}
|
||||
>
|
||||
{row.name || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.account || "—"}</TableCell>
|
||||
<TableCell>{row.title || "—"}</TableCell>
|
||||
<TableCell>{row.email || "—"}</TableCell>
|
||||
<TableCell>{row.phoneNumber || "—"}</TableCell>
|
||||
<TableCell>{row.owner || "—"}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => navigate(`/contacts/edit/${row.id}`)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => setDeleteTarget(row)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Contact</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteContact.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleteContact.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
deleteContact.mutate(deleteTarget.id, {
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/contacts/new.tsx
Normal file
1
src/app/(protected)/contacts/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/contact-form";
|
||||
61
src/domain/contacts/api/contacts-api.ts
Normal file
61
src/domain/contacts/api/contacts-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 {
|
||||
mapContact,
|
||||
mapContactListItems,
|
||||
mapContactToBackend,
|
||||
} from "@/domain/contacts/mappers/contact-mapper";
|
||||
import type {
|
||||
Contact,
|
||||
ContactsListParams,
|
||||
ContactsListResult,
|
||||
} from "@/domain/contacts/types/contact";
|
||||
|
||||
function buildListParams(params: ContactsListParams): Record<string, string | number> {
|
||||
return {
|
||||
search: params.search ?? "",
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 12,
|
||||
};
|
||||
}
|
||||
|
||||
export const contactsApi = {
|
||||
getList: async (params: ContactsListParams = {}): Promise<ContactsListResult> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.contact.getList, {
|
||||
searchParams: buildListParams(params),
|
||||
});
|
||||
const paginated = handlePaginatedResponse<unknown>(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<Contact> => {
|
||||
const data = await apiGet<unknown>(`${API_PATHS.rest.contacts}/${id}`);
|
||||
return mapContact(handleApiResponse(data));
|
||||
},
|
||||
|
||||
create: async (payload: unknown): Promise<Contact> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.rest.contacts, mapContactToBackend(payload));
|
||||
return mapContact(handleApiResponse(data));
|
||||
},
|
||||
|
||||
update: async (id: string | number, payload: unknown): Promise<Contact> => {
|
||||
const data = await apiPut<unknown>(
|
||||
`${API_PATHS.rest.contacts}/${id}`,
|
||||
mapContactToBackend(payload),
|
||||
);
|
||||
return mapContact(handleApiResponse(data));
|
||||
},
|
||||
|
||||
delete: async (id: string | number): Promise<void> => {
|
||||
await apiDelete<unknown>(`${API_PATHS.rest.contacts}/${id}`);
|
||||
},
|
||||
};
|
||||
105
src/domain/contacts/mappers/contact-mapper.ts
Normal file
105
src/domain/contacts/mappers/contact-mapper.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { Contact, ContactListItem } from "@/domain/contacts/types/contact";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
const item = asRecord(contact);
|
||||
const payload: Record<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
31
src/domain/contacts/schemas/contact-schema.ts
Normal file
31
src/domain/contacts/schemas/contact-schema.ts
Normal file
|
|
@ -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<typeof contactSchema>;
|
||||
51
src/domain/contacts/types/contact.ts
Normal file
51
src/domain/contacts/types/contact.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
13
src/domain/contacts/use-cases/use-contact-detail.ts
Normal file
13
src/domain/contacts/use-cases/use-contact-detail.ts
Normal file
|
|
@ -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<Contact, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.contacts.detail(id ?? ""),
|
||||
queryFn: () => contactsApi.getById(requireQueryParam(id, "Contact id")),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
14
src/domain/contacts/use-cases/use-contacts-list.ts
Normal file
14
src/domain/contacts/use-cases/use-contacts-list.ts
Normal file
|
|
@ -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<ContactsListResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.contacts.list(params as Record<string, unknown>),
|
||||
queryFn: () => contactsApi.getList(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
24
src/domain/contacts/use-cases/use-create-contact.ts
Normal file
24
src/domain/contacts/use-cases/use-create-contact.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 { 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<Contact, Error, ContactFormValues> {
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
19
src/domain/contacts/use-cases/use-delete-contact.ts
Normal file
19
src/domain/contacts/use-cases/use-delete-contact.ts
Normal file
|
|
@ -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<void, Error, string | number> {
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
30
src/domain/contacts/use-cases/use-update-contact.ts
Normal file
30
src/domain/contacts/use-cases/use-update-contact.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 { 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<Contact, Error, UpdateContactInput> {
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue