diff --git a/src/modules/clients/pages/NewClientPage.tsx b/src/modules/clients/pages/NewClientPage.tsx new file mode 100644 index 0000000..283e160 --- /dev/null +++ b/src/modules/clients/pages/NewClientPage.tsx @@ -0,0 +1,371 @@ +import { SidebarMenu } from "@/shared/components/Sidebar"; +import { useToast } from "@/shared/hooks"; +import { getPageTitle } from "@/shared/utils"; +import { Ban, Save } from "lucide-react"; +import { useState, type SyntheticEvent, type ChangeEvent } from "react"; +import { Helmet } from "react-helmet-async"; +import { createDocument } from "@/services/firebase/firestore"; +import { InputField } from "@/shared/components/InputField"; +import { Button } from "@/shared/components/Button"; +import { useNavigate } from "react-router-dom"; +import { fetchAddressByZip } from "@/shared/utils/fetchAddressByZip"; +import { validateClientForm } from "../validators/validateClientForm"; +import { formatTaxId } from "@/shared/utils/masks/formatTaxId.ts"; +import { formatZipCode } from "@/shared/utils/masks/formatZipCode.ts"; + +export function NewClientPage() { + const [form, setForm] = useState({ + firstName: "", + lastName: "", + email: "", + phone: "", + taxId: "", + birthDate: "", + gender: "", + city: "", + state: "", + address: "", + zipCode: "", + number: "", + }); + + const [loading, setLoading] = useState(false); + const [zipLoaded, setZipLoaded] = useState(false); + const [errors, setErrors] = useState>({}); + + const { addToast } = useToast(); + const navigate = useNavigate(); + + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + + let newValue = value; + + if (name === "taxId") { + newValue = formatTaxId(value); + } + + setForm((prev) => ({ + ...prev, + [name]: newValue, + })); + + setErrors((prev) => ({ + ...prev, + [name]: "", + })); + }; + + const handleZipCodeChange = async (e: ChangeEvent) => { + const zip = formatZipCode(e.target.value); + + setForm((prev) => ({ + ...prev, + zipCode: zip, + address: "", + city: "", + state: "", + number: "", + })); + + const cleanZip = zip.replace(/\D/g, ""); + + setZipLoaded(false); + + if (cleanZip.length < 8) { + return; + } + + if (cleanZip.length > 8) { + setErrors((prev) => ({ + ...prev, + zipCode: "ZIP Code must have 8 digits", + })); + return; + } + + const address = await fetchAddressByZip(cleanZip); + + if (!address) { + setErrors((prev) => ({ + ...prev, + zipCode: "Invalid ZIP Code", + })); + return; + } + + setForm((prev) => ({ + ...prev, + zipCode: zip, + address: address.address, + city: address.city, + state: address.state, + })); + + setZipLoaded(true); + + setErrors((prev) => ({ + ...prev, + zipCode: "", + })); + }; + + const handleClientCreation = async (e: SyntheticEvent) => { + e.preventDefault(); + setLoading(true); + + try { + const validationErrors = validateClientForm(form); + + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + setLoading(false); + return; + } + + const now = new Date().toISOString(); + + const client = { + ...form, + createdAt: now, + updatedAt: now, + }; + + await createDocument("clients", client as never); + + addToast({ + message: "Client created successfully", + variant: "success", + duration: 3000, + }); + + navigate("/clients"); + } catch (err: unknown) { + console.error("Failed to create client", err); + + addToast({ + message: "Failed to create client. Please try again.", + variant: "error", + duration: 3000, + }); + } finally { + setLoading(false); + } + }; + + return ( + <> + + {getPageTitle("New Client")} + + + +
+
+
+
+

New Client

+ +

+ Add a new client to your database. Provide personal and contact details to manage your relationships. +

+
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+ + + + {errors.gender &&

{errors.gender}

} +
+ + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+
+ + ); +} diff --git a/src/modules/clients/pages/index.tsx b/src/modules/clients/pages/index.tsx index 6b62f00..6b47ce0 100644 --- a/src/modules/clients/pages/index.tsx +++ b/src/modules/clients/pages/index.tsx @@ -1 +1,2 @@ export { ClientsPage } from "./ClientsPage"; +export { NewClientPage } from "./NewClientPage"; diff --git a/src/modules/clients/types/Client.types.ts b/src/modules/clients/types/Client.types.ts index 7596c8f..547ab7e 100644 --- a/src/modules/clients/types/Client.types.ts +++ b/src/modules/clients/types/Client.types.ts @@ -11,6 +11,7 @@ export interface Client { state: string; address: string; zipCode: string; + number: string; createdAt: string; updatedAt: string; [key: string]: string; diff --git a/src/modules/clients/validators/validateClientForm.ts b/src/modules/clients/validators/validateClientForm.ts new file mode 100644 index 0000000..e069b9a --- /dev/null +++ b/src/modules/clients/validators/validateClientForm.ts @@ -0,0 +1,83 @@ +import { validateZipCode } from "@/modules/clients/validators/validateZipCode.ts"; + +export function validateClientForm(form: { + firstName: string; + lastName: string; + email: string; + phone: string; + taxId: string; + birthDate: string; + gender: string; + city: string; + state: string; + address: string; + zipCode: string; + number: string; +}) { + function isValidCPF(cpf: string) { + const clean = cpf.replace(/\D/g, ""); + + if (clean.length !== 11 || /^(\d)\1+$/.test(clean)) return false; + + let sum = 0; + for (let i = 0; i < 9; i++) { + sum += Number(clean[i]) * (10 - i); + } + + let firstDigit = (sum * 10) % 11; + if (firstDigit === 10) firstDigit = 0; + if (firstDigit !== Number(clean[9])) return false; + + sum = 0; + for (let i = 0; i < 10; i++) { + sum += Number(clean[i]) * (11 - i); + } + + let secondDigit = (sum * 10) % 11; + if (secondDigit === 10) secondDigit = 0; + + return secondDigit === Number(clean[10]); + } + + const errors: Record = {}; + + if (!form.firstName.trim()) { + errors.firstName = "First name is required"; + } + + if (!form.lastName.trim()) { + errors.lastName = "Last name is required"; + } + + if (!form.email.trim()) { + errors.email = "Email is required"; + } else if (!/\S+@\S+\.\S+/.test(form.email)) { + errors.email = "Invalid email"; + } + + if (!form.taxId.trim()) { + errors.taxId = "Tax ID is required"; + } else if (!isValidCPF(form.taxId)) { + errors.taxId = "Invalid Tax ID"; + } + + if (!form.birthDate) { + errors.birthDate = "Birth date is required"; + } + + if (!form.gender) { + errors.gender = "Gender is required"; + } + + const zipError = validateZipCode(form.zipCode, form.address, form.city, form.state); + + if (zipError) { + errors.zipCode = zipError; + } + + if (!form.number.trim()) { + errors.number = "Number is required"; + } + + return errors; +} diff --git a/src/modules/clients/validators/validateZipCode.ts b/src/modules/clients/validators/validateZipCode.ts new file mode 100644 index 0000000..96e9bdf --- /dev/null +++ b/src/modules/clients/validators/validateZipCode.ts @@ -0,0 +1,21 @@ +export function validateZipCode(zipCode: string, address?: string, city?: string, state?: string) { + const cleanZip = zipCode.replace(/\D/g, ""); + + if (!cleanZip) { + return "ZIP Code is required"; + } + + if (cleanZip.length < 8) { + return "Please enter a valid ZIP Code"; + } + + if (cleanZip.length > 8) { + return "ZIP Code must have 8 digits"; + } + + if (!address || !city || !state) { + return "Please enter a valid ZIP Code"; + } + + return ""; +} diff --git a/src/router/components/AppRoute.tsx b/src/router/components/AppRoute.tsx index d9869e5..dfd242b 100644 --- a/src/router/components/AppRoute.tsx +++ b/src/router/components/AppRoute.tsx @@ -1,4 +1,4 @@ -import { ClientsPage } from "@/modules/clients/pages"; +import { ClientsPage, NewClientPage } from "@/modules/clients/pages"; import { OrdersPage } from "@/modules/orders/pages"; import { NewServicePage, ServicesPage } from "@/modules/services/pages"; import { NotFoundPage } from "@/shared/pages"; @@ -50,6 +50,15 @@ export function AppRoute() { } /> + + + + } + /> +