diff --git a/src/App.tsx b/src/App.tsx index 5270316..80e4d00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import Account from "./components/accounts/Account"; import TransactionView from "./components/transactions/TransactionView"; import Templates from "./components/templates/Templates"; import DocumentCompletionScreen from "./components/process/CompletionScreen/DocumentCompletionScreen"; +import Contacts from "./components/contacts/Contacts"; const App = () => { return ( @@ -43,6 +44,7 @@ const App = () => { } /> {/* } /> */} } /> + } /> {/* */} } /> diff --git a/src/components/contacts/Contacts.tsx b/src/components/contacts/Contacts.tsx new file mode 100644 index 0000000..da45268 --- /dev/null +++ b/src/components/contacts/Contacts.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState, useRef } from 'react'; +import { FaEllipsisV } from 'react-icons/fa'; +import api from '../../config/api'; +import ContactFormModal from './ContactsFormModal'; +import { Bounce, toast } from 'react-toastify'; +import { IoMdAdd } from 'react-icons/io'; + +const Contacts = () => { + const activeAccountId = JSON.parse(sessionStorage.getItem('activeAccountId') || 'null'); + const [contacts, setContacts] = useState([]); + const [loading, setLoading] = useState(true); + const [openMenuId, setOpenMenuId] = useState(null); + const blurTimeoutRef = useRef(null); + const [createAndInviteLoader, setCreateAndInviteLoader] = useState(false); + const [showModal, setShowModal] = useState(false) + + const fetchContacts = async () => { + try { + const res = await api.get(`/api/accounts/${activeAccountId}/contacts`); + setContacts(res.data.content || []); + } catch (error) { + console.error('Failed to fetch contacts:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (activeAccountId) { + fetchContacts(); + } + }, [activeAccountId]); + + const handleEdit = (contactId) => { + console.log('Edit contact:', contactId); + setOpenMenuId(null); + }; + + const handleDelete = async(contactId) => { + console.log('Delete contact:', contactId); + setOpenMenuId(null); + try { + let res = await api.delete(`/api/accounts/{accountId}/contacts/{contactId}`) + if (res?.data) { + + } + } catch (error) { + console.log(error) + } + }; + + const toggleMenu = (contactId) => { + // Cancel any pending blur timeout (prevents blinking) + clearTimeout(blurTimeoutRef.current); + setOpenMenuId((prevId) => (prevId === contactId ? null : contactId)); + }; + + const handleBlur = () => { + blurTimeoutRef.current = setTimeout(() => { + setOpenMenuId(null); + }, 100); + }; + + const handleContactSubmit = async (data: ContactData) => { + try { + setCreateAndInviteLoader(true); + const res = await api.post(`/api/accounts/${activeAccountId}/contacts`, [data]); + if (res.status === 200) { + toast.success('Contact created successfully!', { + position: "top-center", + autoClose: 1500, + transition: Bounce, + }); + setShowModal(false) + fetchContacts(); + } + // return res; + } catch (err) { + console.error("Error creating contact", err); + toast.error("Failed to create contact", { + position: "top-center", + autoClose: 1500, + transition: Bounce, + }); + throw err; + } finally { + setCreateAndInviteLoader(false); + } + }; + + return ( + <> +
+
setShowModal((prev) => !prev)} + className="flex items-center gap-2 bg-[var(--primary)] px-3 py-1.5 rounded-sm hover:opacity-90 "> + + +
+
+
+ + + + + + + + + + + {loading ? ( + + + + ) : contacts.length === 0 ? ( + + + + ) : ( + contacts.map((contact) => ( + + + + + + + )) + )} + +
First NameLast NameEmailActions
Loading...
No contacts found.
{contact.firstName}{contact.lastName}{contact.email} +
+ + + {openMenuId === contact.id && ( +
+ + +
+ )} +
+
+ {showModal && + + } +
+ + ); +}; + +export default Contacts; diff --git a/src/components/contacts/ContactsFormModal.tsx b/src/components/contacts/ContactsFormModal.tsx new file mode 100644 index 0000000..ea6416a --- /dev/null +++ b/src/components/contacts/ContactsFormModal.tsx @@ -0,0 +1,245 @@ +// ContactForm.tsx +import React, { useState } from 'react'; +import { FaSpinner } from 'react-icons/fa'; + +export interface ContactData { + contactId?: number; + email: string; + firstName: string; + lastName: string; + title: string; + company: string; + website: string; + phones: string[]; +} + +interface Props { + onSubmit: (data: ContactData) => void; + loading: boolean; + setShowModal: () => React.Dispatch> +} + +const ContactFormModal = ({ onSubmit, loading, setShowModal }: Props) => { + const [formData, setFormData] = useState({ + email: '', + firstName: '', + lastName: '', + title: '', + company: '', + website: '', + phones: [''], + }); + + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + + const handleChange = (field: keyof ContactData, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: '' })); + } + }; + + const handlePhoneChange = (index: number, value: string) => { + const numericValue = value.replace(/\D/g, ''); // Remove non-numeric + const updatedPhones = [...formData.phones]; + updatedPhones[index] = numericValue; + setFormData((prev) => ({ ...prev, phones: updatedPhones })); + + const errorKey = `phones_${index}`; + if (errors[errorKey]) { + setErrors((prev) => ({ ...prev, [errorKey]: '' })); + } + }; + + + const addPhoneField = () => { + setFormData((prev) => ({ ...prev, phones: [...prev.phones, ''] })); + }; + + const removePhoneField = (index: number) => { + const updatedPhones = formData.phones.filter((_, i) => i !== index); + setFormData((prev) => ({ ...prev, phones: updatedPhones })); + }; + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + + const nameRegex = /^[a-zA-Z\s\-']+$/; + const urlRegex = /^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/.*)?$/; + const phoneRegex = /^\d+$/; + + // Email + if (!formData.email.trim()) { + newErrors.email = 'Email is required'; + } else if (!/\S+@\S+\.\S+/.test(formData.email)) { + newErrors.email = 'Invalid email address'; + } + + // // First Name + // if (!formData.firstName.trim()) { + // newErrors.firstName = 'First name is required'; + // } else if (!nameRegex.test(formData.firstName)) { + // newErrors.firstName = 'First name contains invalid characters'; + // } + + // Last Name + if (!formData.lastName.trim()) { + newErrors.lastName = 'Last name is required'; + } else if (!nameRegex.test(formData.lastName)) { + newErrors.lastName = 'Last name contains invalid characters'; + } + + // // Title + // if (!formData.title.trim()) { + // newErrors.title = 'Title is required'; + // } + + + // Website (optional but validate if present) + if (formData.website.trim() && !urlRegex.test(formData.website)) { + newErrors.website = 'Invalid website URL'; + } + + // Phones + // formData.phones.forEach((phone, index) => { + // if (!phone.trim()) { + // newErrors[`phones_${index}`] = 'Phone number is required'; + // } else if (!phoneRegex.test(phone)) { + // newErrors[`phones_${index}`] = 'Phone number must contain only digits'; + // } + // }); + + return newErrors; + }; + + + const handleSubmit = async () => { + const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + + try { + await onSubmit(formData); + + setFormData({ + email: '', + firstName: '', + lastName: '', + title: '', + company: '', + website: '', + phones: [''], + }); + setErrors({}); + } catch (err) { + console.error('Submit failed:', err); + } + }; + + + return ( +
+
+
+

Add Contact

+ +
+ {[ + { label: 'First Name', field: 'firstName' }, + { label: 'Last Name', field: 'lastName' }, + { label: 'Email', field: 'email' }, + { label: 'Title', field: 'title' }, + { label: 'Company', field: 'company' }, + { label: 'Website', field: 'website' }, + ].map(({ label, field }) => ( +
+ + handleChange(field as keyof ContactData, e.target.value)} + required={true} + className="px-3 py-1.5 border border-gray-400 rounded-md focus:outline-none focus:ring-1 focus:ring-[var(--primary)] text-sm" + placeholder={`Enter ${label}`} + /> + {errors[field] && {errors[field]}} +
+ ))} +
+ +
+ +
+ {formData.phones.map((phone, index) => ( +
+
+ handlePhoneChange(index, e.target.value)} + className="flex-1 px-3 py-1.5 border border-gray-400 rounded-md focus:outline-none focus:ring-1 focus:ring-[var(--primary)] text-sm" + maxLength={15} + placeholder='Enter Phone Numbers' + /> + {formData.phones.length > 1 && ( + + )} +
+ {errors[`phones_${index}`] && ( + {errors[`phones_${index}`]} + )} +
+ ))} + + +
+
+ +
+ + + + + +
+
+
+
+ ); +}; + +export default ContactFormModal; diff --git a/src/components/dashboard/SideBar.tsx b/src/components/dashboard/SideBar.tsx index 8c7b7a6..398e3bd 100644 --- a/src/components/dashboard/SideBar.tsx +++ b/src/components/dashboard/SideBar.tsx @@ -10,7 +10,7 @@ import { IoSettingsOutline } from "react-icons/io5"; import axios from 'axios'; import { MdManageAccounts } from 'react-icons/md'; -import { GrTransaction } from 'react-icons/gr'; +import { GrContactInfo, GrTransaction } from 'react-icons/gr'; type SideBarProps = { showSideBar: boolean; @@ -66,6 +66,7 @@ const SideBar: React.FC = ({ showSideBar, setShowSideBar }) => { } label="Templates" showSideBar={showSideBar} menuPathMap={menuPathMap} /> {/* } label="Accounts" showSideBar={showSideBar} menuPathMap={menuPathMap} /> */} } label="Transactions" showSideBar={showSideBar} menuPathMap={menuPathMap} /> + } label="Contacts" showSideBar={showSideBar} menuPathMap={menuPathMap} /> diff --git a/src/components/setting/ContactForm.tsx b/src/components/setting/ContactForm.tsx index ba9b964..920b650 100644 --- a/src/components/setting/ContactForm.tsx +++ b/src/components/setting/ContactForm.tsx @@ -159,6 +159,7 @@ const ContactForm = ({ onSubmit, loading }: Props) => { onChange={(e) => handleChange(field as keyof ContactData, e.target.value)} required={true} className="px-3 py-1.5 border border-gray-400 rounded-md focus:outline-none focus:ring-1 focus:ring-[var(--primary)] text-sm" + placeholder={`Enter ${label}`} /> {errors[field] && {errors[field]}} @@ -177,6 +178,7 @@ const ContactForm = ({ onSubmit, loading }: Props) => { onChange={(e) => handlePhoneChange(index, e.target.value)} className="flex-1 px-3 py-1.5 border border-gray-400 rounded-md focus:outline-none focus:ring-1 focus:ring-[var(--primary)] text-sm" maxLength={15} + placeholder='Enter Phone Numbers' /> {formData.phones.length > 1 && (