contact-list
This commit is contained in:
parent
0361ae7c04
commit
69796039ba
|
|
@ -20,6 +20,7 @@ import Account from "./components/accounts/Account";
|
||||||
import TransactionView from "./components/transactions/TransactionView";
|
import TransactionView from "./components/transactions/TransactionView";
|
||||||
import Templates from "./components/templates/Templates";
|
import Templates from "./components/templates/Templates";
|
||||||
import DocumentCompletionScreen from "./components/process/CompletionScreen/DocumentCompletionScreen";
|
import DocumentCompletionScreen from "./components/process/CompletionScreen/DocumentCompletionScreen";
|
||||||
|
import Contacts from "./components/contacts/Contacts";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -43,6 +44,7 @@ const App = () => {
|
||||||
<Route path="/accounts/:accountId/transactions/:transactionId" element={<TransactionView />} />
|
<Route path="/accounts/:accountId/transactions/:transactionId" element={<TransactionView />} />
|
||||||
{/* <Route path="/transactions/:filterParam" element={<Transactions />} /> */}
|
{/* <Route path="/transactions/:filterParam" element={<Transactions />} /> */}
|
||||||
<Route path="/templates" element={<Templates />} />
|
<Route path="/templates" element={<Templates />} />
|
||||||
|
<Route path="/contacts" element={<Contacts />} />
|
||||||
{/* </Route> */}
|
{/* </Route> */}
|
||||||
|
|
||||||
<Route path="/s/:page1" element={<Setting />} />
|
<Route path="/s/:page1" element={<Setting />} />
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className='flex justify-end my-2 cursor-pointer'>
|
||||||
|
<div
|
||||||
|
onClick={() => setShowModal((prev) => !prev)}
|
||||||
|
className="flex items-center gap-2 bg-[var(--primary)] px-3 py-1.5 rounded-sm hover:opacity-90 ">
|
||||||
|
<IoMdAdd className="text-white" />
|
||||||
|
<button
|
||||||
|
className="text-white rounded text-sm hover:opacity-90 cursor-pointer">
|
||||||
|
Add Contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto max-h-[79vh] border border-gray-200 rounded shadow-sm no-scrollbar">
|
||||||
|
<table className="min-w-full text-sm bg-white">
|
||||||
|
<thead className="bg-gray-100 text-gray-700 uppercase text-xs tracking-wider sticky top-0 z-10">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">First Name</th>
|
||||||
|
<th className="px-4 py-3 text-left">Last Name</th>
|
||||||
|
<th className="px-4 py-3 text-left">Email</th>
|
||||||
|
<th className="px-4 py-3 text-center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="4" className="text-center py-4">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
) : contacts.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="4" className="text-center py-4">No contacts found.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
contacts.map((contact) => (
|
||||||
|
<tr key={contact.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">{contact.firstName}</td>
|
||||||
|
<td className="px-4 py-3">{contact.lastName}</td>
|
||||||
|
<td className="px-4 py-3">{contact.email}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<div
|
||||||
|
className="relative inline-block"
|
||||||
|
tabIndex={0}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="text-gray-600 hover:text-black p-1 focus:outline-none"
|
||||||
|
onClick={() => toggleMenu(contact.id)}
|
||||||
|
>
|
||||||
|
<FaEllipsisV />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{openMenuId === contact.id && (
|
||||||
|
<div className="absolute top-full right-0 mt-1 bg-white border border-gray-200 rounded shadow-lg z-20 min-w-[100px]">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(contact.id)}
|
||||||
|
className="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 text-blue-600"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(contact.id)}
|
||||||
|
className="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 text-red-600"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{showModal &&
|
||||||
|
<ContactFormModal onSubmit={handleContactSubmit}
|
||||||
|
loading={createAndInviteLoader} setShowModal={setShowModal}/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Contacts;
|
||||||
|
|
@ -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<React.SetStateAction<boolean>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContactFormModal = ({ onSubmit, loading, setShowModal }: Props) => {
|
||||||
|
const [formData, setFormData] = useState<ContactData>({
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/5 backdrop-blur-sm bg-opacity-50">
|
||||||
|
<div className="bg-white rounded-md p-6 max-w-[700px] w-full shadow-lg relative">
|
||||||
|
<div className="ms-2 space-y-2 max-w-[60vw]">
|
||||||
|
<h2 className="text-lg font-medium text-slate-500">Add Contact</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<div key={field} className="flex flex-col">
|
||||||
|
<label className="text-sm font-medium text-[var(--greyTextCol)] mb-1">{label}</label>
|
||||||
|
<input
|
||||||
|
type={field === 'email' ? 'email' : 'text'}
|
||||||
|
value={formData[field as keyof ContactData] as string}
|
||||||
|
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] && <span className="text-sm text-red-500 mt-1">{errors[field]}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-[var(--greyTextCol)] mb-1">Phone Numbers</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{formData.phones.map((phone, index) => (
|
||||||
|
<div key={index} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={phone}
|
||||||
|
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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePhoneField(index)}
|
||||||
|
className="text-xs text-red-500 hover:underline"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors[`phones_${index}`] && (
|
||||||
|
<span className="text-sm text-red-500">{errors[`phones_${index}`]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPhoneField}
|
||||||
|
className="text-sm text-blue-500 hover:underline mt-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
+ Add another phone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal((prev) => !prev)}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gray-300 px-5 py-1.5 rounded-md text-slate-700 text-sm font-medium hover:bg-opacity-90 transition disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer me-3"
|
||||||
|
> Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gray-300 px-5 py-1.5 rounded-md text-slate-700 text-sm font-medium hover:bg-opacity-90 transition disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin inline-block mr-2" />
|
||||||
|
SAVING...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'SAVE CONTACT'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactFormModal;
|
||||||
|
|
@ -10,7 +10,7 @@ import { IoSettingsOutline } from "react-icons/io5";
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { MdManageAccounts } from 'react-icons/md';
|
import { MdManageAccounts } from 'react-icons/md';
|
||||||
import { GrTransaction } from 'react-icons/gr';
|
import { GrContactInfo, GrTransaction } from 'react-icons/gr';
|
||||||
|
|
||||||
type SideBarProps = {
|
type SideBarProps = {
|
||||||
showSideBar: boolean;
|
showSideBar: boolean;
|
||||||
|
|
@ -66,6 +66,7 @@ const SideBar: React.FC<SideBarProps> = ({ showSideBar, setShowSideBar }) => {
|
||||||
<NavLink to="/templates" icon={<HiTemplate />} label="Templates" showSideBar={showSideBar} menuPathMap={menuPathMap} />
|
<NavLink to="/templates" icon={<HiTemplate />} label="Templates" showSideBar={showSideBar} menuPathMap={menuPathMap} />
|
||||||
{/* <NavLink to="/accounts" icon={<MdManageAccounts />} label="Accounts" showSideBar={showSideBar} menuPathMap={menuPathMap} /> */}
|
{/* <NavLink to="/accounts" icon={<MdManageAccounts />} label="Accounts" showSideBar={showSideBar} menuPathMap={menuPathMap} /> */}
|
||||||
<NavLink to="transactions" icon={<GrTransaction />} label="Transactions" showSideBar={showSideBar} menuPathMap={menuPathMap} />
|
<NavLink to="transactions" icon={<GrTransaction />} label="Transactions" showSideBar={showSideBar} menuPathMap={menuPathMap} />
|
||||||
|
<NavLink to="/contacts" icon={<GrContactInfo />} label="Contacts" showSideBar={showSideBar} menuPathMap={menuPathMap} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -392,7 +392,7 @@ const Setting = () => {
|
||||||
const handleContactSubmit = async (data: ContactData) => {
|
const handleContactSubmit = async (data: ContactData) => {
|
||||||
try {
|
try {
|
||||||
setCreateAndInviteLoader(true);
|
setCreateAndInviteLoader(true);
|
||||||
const res = await api.post(`/api/accounts/${activeAccountId}/contacts`, [data]); // Adjust endpoint if different
|
const res = await api.post(`/api/accounts/${activeAccountId}/contacts`, [data]);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
toast.success('Contact created successfully!', {
|
toast.success('Contact created successfully!', {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue