jai #1
File diff suppressed because it is too large
Load Diff
|
|
@ -74,8 +74,6 @@ const App = () => {
|
|||
<Route path="/sign/:recipientId" element={<SignDocument />} />
|
||||
<Route path="/view" element={<DocView />} />
|
||||
|
||||
<Route path="/SigningOrderDiagram" element={<SigningOrderDiagram />} />
|
||||
|
||||
{/* Links work wothout Login */}
|
||||
</Routes>
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ const SigningOrderDiagram : React.FC<Signer[]> = (signers) => {
|
|||
) || -1;
|
||||
|
||||
return (
|
||||
<div className="relative p-8 bg-white max-w-max">
|
||||
<div className="relative p-8 bg-white rounded-b-lg max-w-max max-h-[60vh] overflow-auto">
|
||||
{/* Single mail icon on top */}
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ const SigningOrderDiagram : React.FC<Signer[]> = (signers) => {
|
|||
<div className="w-16 border-1 border-dotted border-slate-200 "></div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center space-x-6">
|
||||
<div className="flex items-center justify-center space-x-6 ">
|
||||
{sortedOrders.map((order, index) => (
|
||||
<React.Fragment key={order}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import AccountNav from "./AccountNav";
|
|||
import api from "../../config/api";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { FaCheck } from "react-icons/fa";
|
||||
import Cards from "../home/Cards";
|
||||
import AddInviteModal from "./AddInviteModal";
|
||||
|
||||
interface Transaction {
|
||||
id: number;
|
||||
|
|
@ -23,6 +25,12 @@ const Account = () => {
|
|||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [selectedTransactions, setSelectedTransactions] = useState<number[]>([]);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const fetchTransactions = async () => {
|
||||
try {
|
||||
const response = await api(`/api/accounts/${accountId}/transactions`);
|
||||
|
|
@ -42,8 +50,14 @@ const Account = () => {
|
|||
navigate(`/accounts/${accountId}/transactions/${id}`);
|
||||
};
|
||||
|
||||
const handleAddAccount = (accountName: string) => {
|
||||
console.log(accountId)
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Cards type="account" onOpenModal={handleOpenModal} />
|
||||
{transactions.length > 0 && (
|
||||
<div className="overflow-x-auto py-4">
|
||||
<table className="min-w-full text-sm border border-gray-200 rounded bg-white shadow-sm">
|
||||
|
|
@ -102,6 +116,8 @@ const Account = () => {
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
{isModalOpen && <AddInviteModal onClose={() => setIsModalOpen(false)}
|
||||
onAddAccount={handleAddAccount} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,105 +1,185 @@
|
|||
import React, { use, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { FaCog } from 'react-icons/fa';
|
||||
import { IoIosNotifications } from 'react-icons/io';
|
||||
import { RootState } from '../../store/store';
|
||||
import { UserState } from '../../store/slices/userSlice';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
// import { useSelector } from 'react-redux';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { FaSignOutAlt,FaUser } from "react-icons/fa";
|
||||
import api from '../../config/api';
|
||||
|
||||
// const fetchAccountData = async (accountId: string) => {
|
||||
// try {
|
||||
// if(!accountId) return null;
|
||||
// const response = await api.get(`/api/accounts/${accountId}`);
|
||||
// return response.data;
|
||||
// } catch (error) {
|
||||
// console.error("Error fetching account data:", error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
|
||||
// const fetchTransactionData = async (accountId: string, transactionId: string) => {
|
||||
// try {
|
||||
// if(!accountId || !transactionId) return null;
|
||||
// const response = await api.get(`/api/accounts/${accountId}/transactions/${transactionId}`);
|
||||
// return response.data;
|
||||
// } catch (error) {
|
||||
// console.error("Error fetching account data:", error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
|
||||
// import { RootState } from '../../store/store';
|
||||
// import { UserState } from '../../store/slices/userSlice';
|
||||
|
||||
interface AccountNavProps {
|
||||
accountName: string | null;
|
||||
transactionName: string | null;
|
||||
accountName?: string | null;
|
||||
transactionName?: string | null;
|
||||
}
|
||||
|
||||
// Define the shape of state object
|
||||
interface Account {
|
||||
id: number;
|
||||
isDefault: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
language: string;
|
||||
account: Account;
|
||||
}
|
||||
|
||||
const AccountNav: React.FC<AccountNavProps> = ({ accountName = null, transactionName = null }) => {
|
||||
const user: UserState = useSelector((state: RootState) => state.user);
|
||||
// const user: UserState = useSelector((state: RootState) => state.user);
|
||||
|
||||
const [user, setUser] = useState<UserData>({
|
||||
id: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
language: "",
|
||||
account: {
|
||||
id: 0,
|
||||
isDefault: false,
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const getUserDetails = async () => {
|
||||
try {
|
||||
let res = await api.get(`/api/user/details`)
|
||||
console.log("getuser", res)
|
||||
setUser(res?.data)
|
||||
} catch (error) {
|
||||
console.error("Error fetching user details:", error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getUserDetails()
|
||||
}, [])
|
||||
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const pathnames = location.pathname
|
||||
.split("/")
|
||||
.filter((x) => x && isNaN(Number(x))); // remove numeric IDs
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
accounts: "Accounts",
|
||||
transactions: "Transactions",
|
||||
// Add more routes here if needed
|
||||
};
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicked outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
// Add logout logic here
|
||||
console.log('Logging out...');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const initials = user.username
|
||||
? user.username
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
: 'U';
|
||||
|
||||
// useEffect(() => {
|
||||
// if (accountId) {
|
||||
// api.get(`/api/accounts/${accountId}`)
|
||||
// .then(response => {
|
||||
// // Handle the account data if needed
|
||||
// console.log(response.data, "Line 29");
|
||||
// })
|
||||
// .catch(error => {
|
||||
// console.error("Error fetching account data:", error);
|
||||
// });
|
||||
// }
|
||||
// }, [accountId, transactionId]);
|
||||
|
||||
return (
|
||||
<header className="w-full px-6 py-2 flex justify-between items-center bg-white border-b rounded-sm border-gray-200">
|
||||
<nav className="text-md font-medium text-gray-800 capitalize">
|
||||
{pathnames.map((segment, index) => {
|
||||
const label = labelMap[segment] || segment;
|
||||
const to = "/" + pathnames.slice(0, index + 1).join("/");
|
||||
const isLast = index === pathnames.length - 1;
|
||||
|
||||
<h1 className="text-md font-medium text-gray-800 capitalize">
|
||||
Account
|
||||
{accountName ? ` > ${accountName}` : ''}
|
||||
{transactionName ? ` > ${transactionName}` : ''}
|
||||
</h1>
|
||||
|
||||
{/* <h1 className="text-md font-medium text-gray-800 capitalize">Account</h1> */}
|
||||
|
||||
<div className="flex items-center">
|
||||
{/* Notification + Settings with smaller gap */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
aria-label="Notifications"
|
||||
className="p-2 rounded-full hover:bg-gray-100 transition-colors text-gray-600"
|
||||
return (
|
||||
<span key={to}>
|
||||
{!isLast ? (
|
||||
<>
|
||||
<Link
|
||||
to={to}
|
||||
className="text-blue-600 hover:text-blue-700 hover:underline transition-colors"
|
||||
>
|
||||
<IoIosNotifications className="text-xl" />
|
||||
{label}
|
||||
</Link>
|
||||
<span className="mx-2 text-gray-400">{">"}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-900 font-semibold">{label}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
{/* User Dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Avatar Button */}
|
||||
<button
|
||||
className="flex items-center space-x-2 p-1 cursor-pointer rounded-lg transition-colors"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-green-500 to-blue-600 flex items-center justify-center text-white font-semibold text-sm shadow-md">
|
||||
{user ? (
|
||||
<span
|
||||
className="w-full h-full object-cover p-1.5 font-bold"
|
||||
>{(user.firstName.slice(0,1) + user.lastName.slice(0,1)).toUpperCase()}</span>
|
||||
) : (
|
||||
<FaUser className="text-lg" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to="/settings"
|
||||
aria-label="Settings"
|
||||
className="p-2 rounded-full hover:bg-gray-100 transition-colors text-gray-600"
|
||||
>
|
||||
<FaCog className="text-sm" />
|
||||
</Link>
|
||||
{/* Enhanced Dropdown */}
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-72 top-8 rounded-xl shadow-xl bg-white border border-gray-200 z-50 overflow-hidden">
|
||||
{/* User Info Section */}
|
||||
<div className="px-4 py-4 bg-gradient-to-r from-blue-50 to-purple-50 border-b border-gray-100">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 rounded-full overflow-hidden bg-gradient-to-br from-green-500 to-blue-600 flex items-center justify-center text-white font-semibold shadow-md">
|
||||
{(user) ? (
|
||||
<span
|
||||
className="w-full h-full object-cover px-3 text-xl py-2.5 font-bold"
|
||||
>{(user.firstName.slice(0,1) + user.lastName.slice(0,1)).toUpperCase()}</span>
|
||||
) : (
|
||||
<FaUser className="text-lg" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-gray-900 truncate">{user.firstName + ' ' +user.lastName || '-'}</div>
|
||||
<div className="text-sm text-gray-600 truncate">{user.email || '-'}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Account #{user?.account?.name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar with separate spacing */}
|
||||
<div className="w-9 h-9 ms-3 rounded-full overflow-hidden bg-[var(--primary)] flex items-center justify-center text-white font-semibold text-sm">
|
||||
{user.picture ? (
|
||||
<img
|
||||
src={user.picture}
|
||||
alt="User avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
{/* Logout Section */}
|
||||
<div className="border-t border-gray-100 ">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center px-4 py-3 text-sm text-red-600 hover:bg-red-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<FaSignOutAlt className="mr-3" />
|
||||
<span>Log Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import AccountNav from "./AccountNav";
|
|||
import api from "../../config/api";
|
||||
import { AccountData, AccountStatus, UserRole } from "../../utlity/types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Cards from "../home/Cards";
|
||||
import AddAccountModal from "./AddAccountModal";
|
||||
import { Bounce, toast } from "react-toastify";
|
||||
|
||||
// import { UserRole, AccountStatus, AccountData } from "../../types/account";
|
||||
|
||||
|
|
@ -32,6 +35,13 @@ const getStatusLabel = (
|
|||
const Accounts = () => {
|
||||
const [allAccounts, setAllAccounts] = useState<AccountData[]>([]);
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<number[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [accounts, setAccounts] = useState<string[]>([]);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -53,8 +63,43 @@ const Accounts = () => {
|
|||
navigate(`/accounts/${id}/transactions`);
|
||||
};
|
||||
|
||||
const handleAddAccount = (accountName: string) => {
|
||||
setAccounts((prev) => [...prev, accountName]);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleMenuToggle = (accountId: string) => {
|
||||
setOpenMenuId(openMenuId === accountId ? null : accountId);
|
||||
};
|
||||
|
||||
const handleAction = async (accountId: string, action: 'accept' | 'reject') => {
|
||||
try {
|
||||
let response = await api.post(`/api/accounts/${accountId}/accept/invite`)
|
||||
console.log(response)
|
||||
if (response.data.status === 202) {
|
||||
toast.success("Login Successful", {
|
||||
position: "top-center",
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: false,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
theme: "light",
|
||||
transition: Bounce,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error posting data:', error);
|
||||
} finally {
|
||||
setOpenMenuId(null); // close menu
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Cards type="account" onOpenModal={handleOpenModal} />
|
||||
{allAccounts.length > 0 && (
|
||||
<div className="overflow-x-auto py-2">
|
||||
<table className="min-w-full text-sm border border-gray-200 rounded bg-white shadow-sm">
|
||||
|
|
@ -101,8 +146,38 @@ const Accounts = () => {
|
|||
{status.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<PiDotsThreeOutlineVerticalFill className="text-lg text-slate-300 hover:text-black mx-auto" />
|
||||
<td className="px-4 py-3 text-center relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuToggle(account.accountId);
|
||||
}}
|
||||
className="mx-auto"
|
||||
aria-label="Actions menu"
|
||||
>
|
||||
<PiDotsThreeOutlineVerticalFill className="text-lg text-slate-300 hover:text-black" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{openMenuId === account.accountId && (
|
||||
<div
|
||||
className="absolute right-13 top-6 mt-1 w-18 bg-white border text-[13px] font-bold border-gray-300 rounded-md shadow-lg z-10"
|
||||
onClick={(e) => e.stopPropagation()} // prevent row click closing
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-1 text-left text-green-700 hover:bg-green-100 rounded-t-md cursor-pointer"
|
||||
onClick={() => handleAction(account.accountId, 'accept')}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-1 text-left text-red-700 hover:bg-red-100 rounded-b-md cursor-pointer"
|
||||
onClick={() => handleAction(account.accountId, 'reject')}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
@ -111,6 +186,8 @@ const Accounts = () => {
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
{isModalOpen && <AddAccountModal onClose={() => setIsModalOpen(false)}
|
||||
onAddAccount={handleAddAccount} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
interface AddAccountModalProps {
|
||||
onClose: () => void;
|
||||
onAddAccount: (accountName: string) => void;
|
||||
}
|
||||
|
||||
const AddAccountModal: React.FC<AddAccountModalProps> = ({ onClose, onAddAccount }) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const handleAddClick = () => {
|
||||
if (inputValue.trim()) {
|
||||
onAddAccount(inputValue.trim());
|
||||
setInputValue(''); // clear input after submit
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-80">
|
||||
<h3 className="text-lg font-medium text-gray-800 mb-4">Add New Account</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter account name"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
className="w-full mb-4 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setInputValue('');
|
||||
onClose();
|
||||
}}
|
||||
className="px-4 py-1.5 text-[13px] cursor-pointer font-semibold text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddClick}
|
||||
className="px-2 py-1.5 cursor-pointer font-semibold bg-[var(--modalSubmitBG)] text-[13px] text-white hover:bg-[var(--modalSubmitBGHover)] rounded-md transition-colors"
|
||||
disabled={!inputValue.trim()}
|
||||
>
|
||||
Add Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAccountModal;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { useState } from "react";
|
||||
|
||||
interface AddAccountModalProps {
|
||||
onClose: () => void;
|
||||
onAddAccount: (accountName: string) => void;
|
||||
}
|
||||
const AddInviteModal : React.FC<AddAccountModalProps> = ({ onClose, onAddAccount }) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const handleAddClick = () => {
|
||||
if (inputValue.trim()) {
|
||||
onAddAccount(inputValue.trim());
|
||||
setInputValue(''); // clear input after submit
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-80">
|
||||
<h3 className="text-lg font-medium text-gray-800 mb-4">Send Invite</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter account name"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
className="w-full mb-4 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setInputValue('');
|
||||
onClose();
|
||||
}}
|
||||
className="px-4 py-1.5 text-[13px] cursor-pointer font-semibold text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddClick}
|
||||
className="px-2 py-1.5 cursor-pointer font-semibold bg-[var(--modalSubmitBG)] text-[13px] text-white hover:bg-[var(--modalSubmitBGHover)] rounded-md transition-colors"
|
||||
disabled={!inputValue.trim()}
|
||||
>
|
||||
Add Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddInviteModal
|
||||
|
|
@ -1,7 +1,22 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FaCheckCircle, FaClock, FaHourglassHalf, FaTasks } from 'react-icons/fa';
|
||||
|
||||
const cards = [
|
||||
// Define a type for card data
|
||||
interface CardData {
|
||||
title: string;
|
||||
count: number;
|
||||
icon: JSX.Element;
|
||||
ring: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
interface CardsProps {
|
||||
type?: 'dashboard';
|
||||
// onCardClick?: (card: CardData) => void;
|
||||
onOpenModal?: () => void;
|
||||
}
|
||||
|
||||
const cards: CardData[] = [
|
||||
{
|
||||
title: 'Action Required',
|
||||
count: 12,
|
||||
|
|
@ -32,22 +47,62 @@ const cards = [
|
|||
},
|
||||
];
|
||||
|
||||
const AccountData: CardData[] = [
|
||||
{
|
||||
title: 'Transaction',
|
||||
count: 12,
|
||||
icon: <FaTasks size={20} className="text-orange-500" />,
|
||||
ring: 'ring-orange-400/20',
|
||||
bg: 'bg-orange-50',
|
||||
},
|
||||
{
|
||||
title: 'In-Progress',
|
||||
count: 8,
|
||||
icon: <FaClock size={20} className="text-yellow-500" />,
|
||||
ring: 'ring-yellow-400/20',
|
||||
bg: 'bg-yellow-50',
|
||||
},
|
||||
{
|
||||
title: 'Deactive',
|
||||
count: 5,
|
||||
icon: <FaHourglassHalf size={20} className="text-red-500" />,
|
||||
ring: 'ring-red-400/20',
|
||||
bg: 'bg-red-50',
|
||||
},
|
||||
{
|
||||
title: 'Completed',
|
||||
count: 27,
|
||||
icon: <FaCheckCircle size={20} className="text-green-500" />,
|
||||
ring: 'ring-green-400/20',
|
||||
bg: 'bg-green-50',
|
||||
},
|
||||
];
|
||||
|
||||
const filters = ['Last 7 Days', 'Last 30 Days', 'Last 6 Months', 'This Year'];
|
||||
|
||||
const Cards = () => {
|
||||
const [selectedFilter, setSelectedFilter] = useState('Last 7 Days');
|
||||
const Cards: React.FC<CardsProps> = ({ type, onOpenModal }) => {
|
||||
const [selectedFilter, setSelectedFilter] = useState<string>('Last 7 Days');
|
||||
const [showCards, setShowCards] = useState<CardData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'account') {
|
||||
setShowCards(AccountData);
|
||||
} else {
|
||||
setShowCards(cards);
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<div className="w-full px-4 py-3">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-5 gap-3">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-5 gap-3">
|
||||
<h2 className="text-lg font-medium text-gray-700">Overview</h2>
|
||||
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
{/* Account Selector */}
|
||||
<select
|
||||
value={'All'}
|
||||
onChange={() => {}}
|
||||
onChange={() => { }}
|
||||
className="text-sm text-gray-700 border border-gray-300 rounded-sm px-3 py-1.5 focus:outline-none bg-white shadow-sm"
|
||||
>
|
||||
<option>All</option>
|
||||
|
|
@ -66,14 +121,15 @@ const Cards = () => {
|
|||
<option key={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{type === 'account' &&
|
||||
<button className="px-2 py-1.5 cursor-pointer font-semibold bg-[var(--primary)] text-[13px] text-white hover:bg-[var(--primaryLight2)] rounded-md transition-colors" onClick={onOpenModal}>Add Account</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Cards */}
|
||||
<div className="cursor-pointer grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{cards.map((card) => (
|
||||
{showCards.map((card) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className={`rounded-md bg-white/60 backdrop-blur-md border border-gray-200 p-5 transition-transform shadow-md hover:scale-[1.02] hover:shadow-lg`}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const HomeTopNav = () => {
|
|||
</button>
|
||||
|
||||
<Link
|
||||
to="/settings"
|
||||
to="/s/settings"
|
||||
aria-label="Settings"
|
||||
className="p-2 rounded-full hover:bg-gray-100 transition-colors text-gray-600"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
interface SubSection {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface AsideSectionProps {
|
||||
activeSubSection: string;
|
||||
onSubSectionClick: (subSectionId: string) => void;
|
||||
subSections: SubSection[];
|
||||
mainTabTitle: string;
|
||||
}
|
||||
|
||||
const AsideSection = ({
|
||||
activeSubSection,
|
||||
onSubSectionClick,
|
||||
subSections,
|
||||
mainTabTitle
|
||||
}: AsideSectionProps) => {
|
||||
// If no subsections, don't render the aside navigation
|
||||
if (subSections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[16vw] p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">{mainTabTitle}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Navigate through sections</p>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{subSections.map((subSection) => (
|
||||
<a
|
||||
key={subSection.id}
|
||||
href={`#${subSection.id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // Prevent default anchor behavior
|
||||
onSubSectionClick(subSection.id);
|
||||
}}
|
||||
className={`block w-full text-left px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 hover:bg-gray-50 cursor-pointer no-underline ${
|
||||
activeSubSection === subSection.id
|
||||
? "bg-blue-50 text-blue-600 border-l-4 border-blue-600 shadow-sm"
|
||||
: "text-gray-600 hover:text-gray-800 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="block">{subSection.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AsideSection;
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { FaSpinner } from 'react-icons/fa';
|
||||
|
||||
export type ActionType = 'create' | 'invite';
|
||||
|
||||
type CreateData = { accountName: string };
|
||||
type InviteData = { i_accountName: number; email: string; role: string };
|
||||
|
||||
export type FormDataType = CreateData | InviteData;
|
||||
|
||||
interface AccountOption {
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
role: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: FormDataType, action: ActionType) => void;
|
||||
createAndInviteLoader: boolean;
|
||||
accountOptions: AccountOption[];
|
||||
accountOptionsLoading: boolean;
|
||||
}
|
||||
|
||||
const CreateAndInvite = ({
|
||||
onSubmit,
|
||||
createAndInviteLoader,
|
||||
accountOptions,
|
||||
accountOptionsLoading
|
||||
}: Props) => {
|
||||
const [isRoleDropdownOpen, setIsRoleDropdownOpen] = useState(false);
|
||||
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
|
||||
const roleDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const accountDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [createFormData, setCreateFormData] = useState<{ accountName: string }>({
|
||||
accountName: '',
|
||||
});
|
||||
|
||||
const [accountSearchTerm, setAccountSearchTerm] = useState('');
|
||||
const [selectedAccount, setSelectedAccount] = useState<AccountOption | null>(null);
|
||||
|
||||
const [inviteFormData, setInviteFormData] = useState<{
|
||||
i_accountName: number;
|
||||
email: string;
|
||||
role: string;
|
||||
}>({
|
||||
i_accountName: 0,
|
||||
email: '',
|
||||
role: '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
const roles = ['ADMIN', 'EDITOR', 'VIEWER'];
|
||||
|
||||
// Handle role dropdown
|
||||
const handleRoleDropdownFocus = () => setIsRoleDropdownOpen(true);
|
||||
|
||||
const handleRoleDropdownBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
setTimeout(() => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (roleDropdownRef.current && !roleDropdownRef.current.contains(activeElement)) {
|
||||
setIsRoleDropdownOpen(false);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleRoleSelect = (role: string) => {
|
||||
setInviteFormData((prev) => ({ ...prev, role }));
|
||||
setIsRoleDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Handle account dropdown
|
||||
const handleAccountDropdownFocus = () => setIsAccountDropdownOpen(true);
|
||||
|
||||
const handleAccountDropdownBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
setTimeout(() => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (accountDropdownRef.current && !accountDropdownRef.current.contains(activeElement)) {
|
||||
setIsAccountDropdownOpen(false);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleAccountSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setAccountSearchTerm(value);
|
||||
|
||||
// Clear selected account if user is typing
|
||||
if (selectedAccount && value !== selectedAccount.accountName) {
|
||||
setSelectedAccount(null);
|
||||
setInviteFormData((prev) => ({ ...prev, i_accountName: 0 }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountSelect = (option: AccountOption) => {
|
||||
setSelectedAccount(option);
|
||||
setAccountSearchTerm(option.accountName);
|
||||
setInviteFormData((prev) => ({ ...prev, i_accountName: option.accountId }));
|
||||
setIsAccountDropdownOpen(false);
|
||||
|
||||
// Clear any existing account name error
|
||||
if (errors.i_accountName) {
|
||||
setErrors(prev => ({ ...prev, i_accountName: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle create form
|
||||
const handleCreateChange = (value: string) => {
|
||||
setCreateFormData({ accountName: value });
|
||||
// Clear error when user starts typing
|
||||
if (errors.accountName) {
|
||||
setErrors(prev => ({ ...prev, accountName: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSubmit = () => {
|
||||
if (!createFormData.accountName.trim()) {
|
||||
setErrors({ accountName: 'Account name is required' });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
onSubmit(createFormData, 'create');
|
||||
};
|
||||
|
||||
// Handle invite form
|
||||
const handleInviteChange = (field: keyof typeof inviteFormData, value: string) => {
|
||||
setInviteFormData((prev) => ({ ...prev, [field]: value }));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteSubmit = () => {
|
||||
const { i_accountName, email, role } = inviteFormData;
|
||||
const errs: { [key: string]: string } = {};
|
||||
|
||||
if (!i_accountName) errs.i_accountName = 'Account name is required';
|
||||
if (!email.trim()) errs.email = 'Email is required';
|
||||
else if (!/\S+@\S+\.\S+/.test(email)) errs.email = 'Invalid email';
|
||||
if (!role.trim()) errs.role = 'Role is required';
|
||||
|
||||
if (Object.keys(errs).length > 0) {
|
||||
setErrors(errs);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
onSubmit(inviteFormData, 'invite');
|
||||
};
|
||||
|
||||
// Filter account options based on search term
|
||||
const filteredAccountOptions = accountOptions.filter((option) =>
|
||||
option.accountName?.toLowerCase().includes(accountSearchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ms-2 space-y-3">
|
||||
{/* Create Section */}
|
||||
<section className="border-gray-200 border-b">
|
||||
<h2 className="text-lg font-medium mb-2 text-slate-500">Create Account</h2>
|
||||
<div className="flex flex-col mb-6">
|
||||
<label className="text-sm font-medium text-[var(--greyTextCol)] mb-1">
|
||||
Account Name
|
||||
</label>
|
||||
<div className="flex w-[40vw]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter account name"
|
||||
value={createFormData.accountName}
|
||||
onChange={(e) => handleCreateChange(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-gray-600 rounded-md border border-gray-400 focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateSubmit}
|
||||
className="ms-1 flex items-center text-[14px] px-3 py-[1.1] bg-gray-300 text-slate-600 font-medium rounded-md border border-gray-300 hover:bg-opacity-90 transition cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={createAndInviteLoader}
|
||||
>
|
||||
{createAndInviteLoader ? (
|
||||
<>
|
||||
<FaSpinner className="animate-spin me-1" />
|
||||
CREATING...
|
||||
</>
|
||||
) : (
|
||||
'CREATE'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.accountName && (
|
||||
<span className="text-sm text-red-500 mt-1">{errors.accountName}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Invite Section */}
|
||||
<section className="border-gray-300">
|
||||
<h2 className="text-lg font-medium mb-2 text-slate-500">Invite</h2>
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
{/* Account Name Dropdown */}
|
||||
<div className="flex flex-col relative">
|
||||
<label className="text-sm font-medium text-[var(--greyTextCol)] mb-1">
|
||||
Account Name
|
||||
</label>
|
||||
<div
|
||||
className="relative w-full"
|
||||
onBlur={handleAccountDropdownBlur}
|
||||
ref={accountDropdownRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={accountOptionsLoading ? "Loading accounts..." : "Select or search account"}
|
||||
value={accountSearchTerm}
|
||||
onChange={handleAccountSearchChange}
|
||||
onFocus={handleAccountDropdownFocus}
|
||||
disabled={accountOptionsLoading}
|
||||
className="w-full px-3 py-1.5 text-gray-600 rounded-md border border-gray-400 focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{accountOptionsLoading && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<FaSpinner className="animate-spin text-gray-400 text-sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Dropdown */}
|
||||
{isAccountDropdownOpen && !accountOptionsLoading && (
|
||||
<div className="absolute w-full bg-white border border-gray-300 rounded-md mt-1 shadow-lg z-10">
|
||||
<ul className="max-h-40 overflow-y-auto">
|
||||
{filteredAccountOptions.length > 0 ? (
|
||||
filteredAccountOptions.map((option) => (
|
||||
<li
|
||||
key={option.accountId}
|
||||
onMouseDown={() => handleAccountSelect(option)}
|
||||
className="px-3 py-2 text-[13px] text-gray-600 hover:bg-gray-100 cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{option.accountName}</span>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="px-3 py-2 text-gray-500">No results found</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errors.i_accountName && (
|
||||
<span className="text-sm text-red-500 mt-1">{errors.i_accountName}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex flex-col">
|
||||
<label className="text-sm font-medium text-[var(--greyTextCol)] mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter email address"
|
||||
value={inviteFormData.email}
|
||||
onChange={(e) => handleInviteChange('email', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-gray-600 rounded-md border border-gray-400 focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
{errors.email && (
|
||||
<span className="text-sm text-red-500 mt-1">{errors.email}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role Dropdown */}
|
||||
<div className="flex flex-col">
|
||||
<label className="text-sm font-medium text-[var(--greyTextCol)] mb-1">
|
||||
Role
|
||||
</label>
|
||||
<div
|
||||
className="relative w-full"
|
||||
onBlur={handleRoleDropdownBlur}
|
||||
ref={roleDropdownRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Select role"
|
||||
value={inviteFormData.role}
|
||||
readOnly
|
||||
onFocus={handleRoleDropdownFocus}
|
||||
className="w-full px-3 py-1.5 border text-gray-600 border-gray-400 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] transition cursor-pointer"
|
||||
/>
|
||||
<div
|
||||
className={`absolute z-10 w-full bg-white text-slate-500 font-semibold border border-gray-200 rounded-md shadow-lg mt-1 transition-all duration-200 ease-in-out ${
|
||||
isRoleDropdownOpen
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-95 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<div
|
||||
key={role}
|
||||
onMouseDown={() => handleRoleSelect(role)}
|
||||
className="px-4 py-1 text-[13px] hover:bg-gray-100 cursor-pointer"
|
||||
>
|
||||
{role}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{errors.role && (
|
||||
<span className="text-sm text-red-500 mt-1">{errors.role}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInviteSubmit}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md cursor-pointer hover:bg-opacity-90 transition text-[14px] bg-gray-300 text-slate-600 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={createAndInviteLoader}
|
||||
>
|
||||
{createAndInviteLoader ? (
|
||||
<>
|
||||
<FaSpinner className="animate-spin me-1" />
|
||||
INVITING...
|
||||
</>
|
||||
) : (
|
||||
'INVITE'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAndInvite;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
interface Account {
|
||||
id: number;
|
||||
isDefault: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
language: string;
|
||||
account: Account;
|
||||
}
|
||||
|
||||
interface UserProfileProps {
|
||||
userData: UserData;
|
||||
}
|
||||
|
||||
const Language : React.FC<UserProfileProps> = ({ userData }) => {
|
||||
return (
|
||||
<div className="max-w-[100vw] bg-white shadow-sm p-5 max-h-screen overflow-hidden">
|
||||
|
||||
<div className="px-3 ms-3 w-1/2 border border-gray-200 rounded-md space-y-3 ">
|
||||
|
||||
{/* Language */}
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<span className="text-gray-700 font-medium">Language</span>
|
||||
<span className="text-gray-500">{userData.language}</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Language
|
||||
|
|
@ -1,51 +1,350 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import SettingNav from "./SettingNav";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useLocation, useNavigate } from "react-router-dom";
|
||||
import api from "../../config/api";
|
||||
import UserProfile from './UserProfile';
|
||||
import Language from "./Language";
|
||||
import CreateAndInvite, { ActionType, FormDataType } from './CreateAndInvite'
|
||||
import AsideSection from "./AsideSection";
|
||||
import Signature from "./Signature/Signature";
|
||||
import { Bounce, toast } from "react-toastify";
|
||||
|
||||
// Define the shape of state object
|
||||
interface Account {
|
||||
id: number;
|
||||
isDefault: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
language: string;
|
||||
account: Account;
|
||||
}
|
||||
|
||||
interface SubSection {
|
||||
id: string;
|
||||
label: string;
|
||||
component?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface AccountOption {
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
role: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const Setting = () => {
|
||||
const { page1 } = useParams<{ page1: string }>();
|
||||
const renderContent = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Refs for subsections within each main tab
|
||||
const subSectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("userProfile");
|
||||
const [activeSubSection, setActiveSubSection] = useState<string>("");
|
||||
const [user, setUser] = useState<UserData>({
|
||||
id: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
language: "",
|
||||
account: {
|
||||
id: 0,
|
||||
isDefault: false,
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const [createAndInviteLoader, setCreateAndInviteLoader] = useState(false);
|
||||
|
||||
// Dropdown data states
|
||||
const [accountOptions, setAccountOptions] = useState<AccountOption[]>([]);
|
||||
const [accountOptionsLoading, setAccountOptionsLoading] = useState(false);
|
||||
|
||||
// Define subsections for each main tab
|
||||
const getSubSections = (tabId: string): SubSection[] => {
|
||||
switch (tabId) {
|
||||
case "userProfile":
|
||||
return [
|
||||
{ id: "profile-info", label: "Profile Information" },
|
||||
{ id: "signature", label: "Signature" },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Function to scroll to specific subsection
|
||||
const scrollToSubSection = (subSectionId: string) => {
|
||||
const targetRef = subSectionRefs.current[subSectionId];
|
||||
|
||||
if (targetRef && scrollContainerRef?.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
const target = targetRef;
|
||||
|
||||
// Calculate the position relative to the scroll container
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
|
||||
// Calculate scroll position (accounting for some padding)
|
||||
const scrollTop = container.scrollTop + targetRect.top - containerRect.top - 20;
|
||||
|
||||
// Smooth scroll to the target
|
||||
container.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle subsection click with hash update
|
||||
const handleSubSectionClick = (subSectionId: string) => {
|
||||
setActiveSubSection(subSectionId);
|
||||
// Update URL hash without causing page reload
|
||||
navigate(`${location.pathname}#${subSectionId}`, { replace: true });
|
||||
scrollToSubSection(subSectionId);
|
||||
};
|
||||
|
||||
// Handle main tab click (independent of aside navigation)
|
||||
const handleMainTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId);
|
||||
// Reset subsection when switching main tabs
|
||||
const subSections = getSubSections(tabId);
|
||||
if (subSections.length > 0) {
|
||||
const firstSubSectionId = subSections[0].id;
|
||||
setActiveSubSection(firstSubSectionId);
|
||||
// Update URL hash
|
||||
navigate(`${location.pathname}#${firstSubSectionId}`, { replace: true });
|
||||
} else {
|
||||
setActiveSubSection("");
|
||||
// Clear hash
|
||||
navigate(location.pathname, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle browser back/forward and direct URL navigation
|
||||
useEffect(() => {
|
||||
const hash = location.hash.replace('#', '');
|
||||
if (hash) {
|
||||
// Check if the hash corresponds to a valid subsection
|
||||
const allSubSections = getSubSections(activeTab);
|
||||
const validSubSection = allSubSections.find(section => section.id === hash);
|
||||
|
||||
if (validSubSection) {
|
||||
setActiveSubSection(hash);
|
||||
// Delay scroll to ensure component is rendered
|
||||
setTimeout(() => {
|
||||
scrollToSubSection(hash);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [location.hash, activeTab]);
|
||||
|
||||
// Fetch account list for dropdown
|
||||
const getAccountList = async () => {
|
||||
setAccountOptionsLoading(true);
|
||||
try {
|
||||
const res = await api.get('/api/user/accounts');
|
||||
if (res?.data) {
|
||||
setAccountOptions(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching account options:', error);
|
||||
} finally {
|
||||
setAccountOptionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render subsection content based on active tab and subsection
|
||||
const renderSubSectionContent = (subSectionId: string) => {
|
||||
switch (activeTab) {
|
||||
case "userProfile":
|
||||
return <div>User Profile content goes here...</div>;
|
||||
case "language":
|
||||
return <div>Language settings content goes here...</div>;
|
||||
case "contactAll":
|
||||
return <div>All Contacts content goes here...</div>;
|
||||
case "contactAdd":
|
||||
return <div>Add Contact form goes here...</div>;
|
||||
case "contactRemove":
|
||||
return <div>Remove Contact UI goes here...</div>;
|
||||
case "contactShare":
|
||||
return <div>Share Contact options go here...</div>;
|
||||
switch (subSectionId) {
|
||||
case "profile-info":
|
||||
return <UserProfile userData={user} />;
|
||||
case "signature":
|
||||
return (
|
||||
<Signature />
|
||||
);
|
||||
default:
|
||||
return <div>Coming Soon...</div>;
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-4">Coming Soon</h3>
|
||||
<p>This section is under development.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
const currentSubSections = getSubSections(activeTab);
|
||||
|
||||
if (currentSubSections.length === 0) {
|
||||
// If no subsections, render the main content directly
|
||||
switch (activeTab) {
|
||||
case "userProfile":
|
||||
return <UserProfile userData={user} />;
|
||||
case "language":
|
||||
return <Language userData={user} />;
|
||||
case "create and invite":
|
||||
return (
|
||||
<CreateAndInvite
|
||||
onSubmit={handleSubmit}
|
||||
createAndInviteLoader={createAndInviteLoader}
|
||||
accountOptions={accountOptions}
|
||||
accountOptionsLoading={accountOptionsLoading}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div className="bg-white p-6 rounded-lg shadow-sm">Coming Soon...</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{currentSubSections.map((subSection) => (
|
||||
<div
|
||||
key={subSection.id}
|
||||
ref={(el) => (subSectionRefs.current[subSection.id] = el)}
|
||||
id={subSection.id} // This creates the anchor for hash navigation
|
||||
className="scroll-mt-6"
|
||||
>
|
||||
{renderSubSectionContent(subSection.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: "userProfile", label: "User Profile", type: "settings" },
|
||||
{ id: "create and invite", label: "Create and invite", type: "settings" },
|
||||
{ id: "language", label: "Language", type: "settings" },
|
||||
{ id: "contactAll", label: "All Contacts", type: "contact" },
|
||||
{ id: "contactAdd", label: "Add Contact", type: "contact" },
|
||||
{ id: "contactRemove", label: "Remove Contact", type: "contact" },
|
||||
{ id: "contactShare", label: "Share Contact", type: "contact" },
|
||||
];
|
||||
|
||||
const [activeTab, setActiveTab] = useState("userProfile");
|
||||
const getUserDetails = async () => {
|
||||
try {
|
||||
let res = await api.get(`/api/user/details`)
|
||||
console.log("getuser", res)
|
||||
setUser(res?.data)
|
||||
} catch (error) {
|
||||
console.error("Error fetching user details:", error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getUserDetails()
|
||||
// Fetch account list when component mounts
|
||||
getAccountList()
|
||||
}, [])
|
||||
|
||||
// Set initial subsection when component mounts or activeTab changes
|
||||
useEffect(() => {
|
||||
const subSections = getSubSections(activeTab);
|
||||
const hash = location.hash.replace('#', '');
|
||||
|
||||
if (subSections.length > 0) {
|
||||
// If there's a hash and it's valid for current tab, use it
|
||||
if (hash && subSections.find(s => s.id === hash)) {
|
||||
setActiveSubSection(hash);
|
||||
}
|
||||
// If no valid hash or no current activeSubSection, use first subsection
|
||||
else if (!activeSubSection || !subSections.find(s => s.id === activeSubSection)) {
|
||||
const firstSubSectionId = subSections[0].id;
|
||||
setActiveSubSection(firstSubSectionId);
|
||||
// Update URL hash if no hash exists
|
||||
if (!hash) {
|
||||
navigate(`${location.pathname}#${firstSubSectionId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Auto-scroll to subsection when activeSubSection changes
|
||||
useEffect(() => {
|
||||
if (activeSubSection) {
|
||||
setTimeout(() => {
|
||||
scrollToSubSection(activeSubSection);
|
||||
}, 100);
|
||||
}
|
||||
}, [activeSubSection]);
|
||||
|
||||
const handleSubmit = async (data: FormDataType, action: ActionType) => {
|
||||
setCreateAndInviteLoader(true)
|
||||
try {
|
||||
let res;
|
||||
let toastMesaage ;
|
||||
if (action === 'create') {
|
||||
console.log('Creating account:', data);
|
||||
const payloadAccount = {
|
||||
name: data.accountName
|
||||
}
|
||||
res = await api.post(`/api/user/accounts`, payloadAccount)
|
||||
console.log("resAccountName", res)
|
||||
if (res.status === 201) {
|
||||
toastMesaage = `${action.toUpperCase()} Created Successfull...!`
|
||||
}
|
||||
} else if (action === 'invite') {
|
||||
console.log('Inviting user:', data);
|
||||
const payloadInvite = {
|
||||
email: data.email,
|
||||
role: data.role
|
||||
}
|
||||
res = await api.post(`/api/accounts/${data.i_accountName}/invite`, payloadInvite)
|
||||
console.log("InviteNAme",res)
|
||||
toastMesaage = "Invite Successfull...!"
|
||||
}
|
||||
|
||||
if (res?.data) {
|
||||
toast.success(toastMesaage, {
|
||||
position: "top-center",
|
||||
autoClose: 1500,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: false,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
theme: "light",
|
||||
transition: Bounce,
|
||||
});
|
||||
|
||||
// Refetch account list after successful submission
|
||||
await getAccountList();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Submission error:', err);
|
||||
} finally {
|
||||
setCreateAndInviteLoader(false)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingNav />
|
||||
|
||||
<div className="min-h-screen bg-[#f9fafa]">
|
||||
{/* Top Nav Tabs */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-8 pt-4">
|
||||
<div className="bg-[#f9fafa] h-[calc(90vh-20px)] flex flex-col mt-2">
|
||||
{/* Main Navigation Tabs */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-8 pt-5">
|
||||
<div className="flex gap-6">
|
||||
{tabs.map((tab) => (
|
||||
tab.type === page1 && <button
|
||||
{tabs.map((tab) =>
|
||||
tab.type === page1 && (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onClick={() => handleMainTabClick(tab.id)}
|
||||
className={`cursor-pointer relative pb-3 text-sm font-medium transition-all duration-200
|
||||
${activeTab === tab.id
|
||||
? "text-blue-600 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-blue-600"
|
||||
|
|
@ -54,14 +353,35 @@ const Setting = () => {
|
|||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="px-8 py-6">{renderContent()}</div>
|
||||
{/* Content with Aside Navigation */}
|
||||
<div className="flex mt-3 px-8 flex-1 min-h-0">
|
||||
{/* Fixed Aside Navigation - Shows subsections of active tab */}
|
||||
{activeTab === 'userProfile' &&
|
||||
<section className="flex-shrink-0 mr-6 w-50 relative">
|
||||
<div className="sticky top-6 z-20">
|
||||
<AsideSection
|
||||
activeSubSection={activeSubSection}
|
||||
onSubSectionClick={handleSubSectionClick}
|
||||
subSections={getSubSections(activeTab)}
|
||||
mainTabTitle={tabs.find(t => t.id === activeTab)?.label || ""}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
{/* Scrollable Main Content */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 min-w-0 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,11 +34,12 @@ const SettingNav = () => {
|
|||
</button>
|
||||
|
||||
<Link
|
||||
to="/settings"
|
||||
to="/s/settings"
|
||||
aria-label="Settings"
|
||||
className="p-2 rounded-full hover:bg-gray-100 transition-colors text-gray-600"
|
||||
>
|
||||
<FaCog className="text-sm" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,529 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { AiOutlineUpload, AiOutlineEdit } from 'react-icons/ai';
|
||||
import { MdCloudUpload } from 'react-icons/md';
|
||||
import api from '../../../config/api';
|
||||
import DrawSignatureCanvas from './DrawSignatureCanvas';
|
||||
import { FaSpinner } from "react-icons/fa";
|
||||
import { GrCheckboxSelected } from "react-icons/gr";
|
||||
import { Bounce, toast } from "react-toastify";
|
||||
|
||||
interface Signature {
|
||||
id: string;
|
||||
email: string;
|
||||
signatureType: string;
|
||||
lastName: string;
|
||||
firstName: string
|
||||
font: string;
|
||||
initialsBlob: string;
|
||||
initialsStampedBlob: string;
|
||||
initialsHash: string;
|
||||
signatureHash: string;
|
||||
signatureBlob: string;
|
||||
signatureStampedBlob: string;
|
||||
default: boolean
|
||||
}
|
||||
|
||||
interface AddSignatureModalProps {
|
||||
setShowAddSignature?: (value: boolean) => void;
|
||||
getAllSignatures: () => Promise<void>;
|
||||
editingSignature?: Signature | null;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: number;
|
||||
isDefault: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
language: string;
|
||||
account: Account;
|
||||
}
|
||||
|
||||
interface SelectStyle {
|
||||
id: number,
|
||||
style: string
|
||||
}
|
||||
|
||||
const AddSignatureModal: React.FC<AddSignatureModalProps> = ({
|
||||
setShowAddSignature,
|
||||
getAllSignatures,
|
||||
editingSignature
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'SELECT' | 'DRAW' | 'UPLOAD'>('SELECT');
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [initials, setInitials] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [signatures, setSignatures] = useState<{
|
||||
fullSignature: string | null;
|
||||
initials: string | null;
|
||||
}>({ fullSignature: null, initials: null });
|
||||
|
||||
const signatureOptions = [
|
||||
{ id: 1, name: 'print', initials: 'LA', style: 'print' },
|
||||
{ id: 2, name: 'cursive', initials: 'LA', style: 'cursive' },
|
||||
{ id: 3, name: 'script', initials: 'LA', style: 'script' },
|
||||
{ id: 4, name: 'elegant', initials: 'LA', style: 'elegant' },
|
||||
{ id: 5, name: 'modern', initials: 'LA', style: 'modern' },
|
||||
{ id: 6, name: 'classic', initials: 'LA', style: 'classic' },
|
||||
];
|
||||
|
||||
const [selectedSignature, setSelectedSignature] = useState<SelectStyle>(
|
||||
signatureOptions.length > 0 ? { id: signatureOptions[0].id, style: signatureOptions[0].style } : { id: 0, style: "" }
|
||||
);
|
||||
|
||||
// Store the actual file
|
||||
const [file1, setFile1] = useState<File | null>(null);
|
||||
const [file2, setFile2] = useState<File | null>(null);
|
||||
|
||||
// Store the preview URL
|
||||
const [preview1, setPreview1] = useState<string | null>(null);
|
||||
const [preview2, setPreview2] = useState<string | null>(null);
|
||||
|
||||
// User data
|
||||
const [user, setUser] = useState<UserData>({
|
||||
id: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
language: "",
|
||||
account: {
|
||||
id: 0,
|
||||
isDefault: false,
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const isEditMode = !!editingSignature;
|
||||
|
||||
// Navigation items
|
||||
const navigationItems = [
|
||||
{ id: 'SELECT', label: 'SELECT', icon: <GrCheckboxSelected className="w-4 h-4" /> },
|
||||
{ id: 'DRAW', label: 'DRAW', icon: <AiOutlineEdit className="w-5 h-5" /> },
|
||||
{ id: 'UPLOAD', label: 'UPLOAD', icon: <AiOutlineUpload className="w-5 h-5" /> },
|
||||
];
|
||||
|
||||
// Initialize form with editing signature data
|
||||
useEffect(() => {
|
||||
if (editingSignature) {
|
||||
setFullName(`${editingSignature.firstName} ${editingSignature.lastName}`);
|
||||
setInitials(`${editingSignature.firstName.slice(0, 1)}${editingSignature.lastName.slice(0, 1)}`);
|
||||
setActiveTab(editingSignature.signatureType as 'SELECT' | 'DRAW' | 'UPLOAD');
|
||||
|
||||
// Set font for SELECT tab
|
||||
const fontOption = signatureOptions.find(option => option.style === editingSignature.font);
|
||||
if (fontOption) {
|
||||
setSelectedSignature({ id: fontOption.id, style: fontOption.style });
|
||||
}
|
||||
|
||||
// Set signature blobs for all tabs
|
||||
if (editingSignature.signatureBlob && editingSignature.initialsBlob) {
|
||||
setSignatures({
|
||||
fullSignature: `data:image/png;base64,${editingSignature.signatureBlob}`,
|
||||
initials: `data:image/png;base64,${editingSignature.initialsBlob}`
|
||||
});
|
||||
|
||||
// Set preview URLs for UPLOAD tab
|
||||
if (editingSignature.signatureType === 'UPLOAD') {
|
||||
setPreview1(`data:image/png;base64,${editingSignature.signatureBlob}`);
|
||||
setPreview2(`data:image/png;base64,${editingSignature.initialsBlob}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [editingSignature]);
|
||||
|
||||
const handleSignatureChange = (data: { fullSignature: string | null; initials: string | null }) => {
|
||||
setSignatures(data);
|
||||
};
|
||||
|
||||
// Cleanup preview URLs when component unmounts or image changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (preview1) URL.revokeObjectURL(preview1);
|
||||
if (preview2) URL.revokeObjectURL(preview2);
|
||||
};
|
||||
}, [preview1, preview2]);
|
||||
|
||||
const handleImageChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
setFile: React.Dispatch<React.SetStateAction<File | null>>,
|
||||
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
type: "fullSignature" | "initials"
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setFile(file);
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setPreview(previewUrl);
|
||||
|
||||
// Convert to base64
|
||||
const base64 = await convertToBase64(file);
|
||||
|
||||
// Update the appropriate signature
|
||||
setSignatures((prev) => ({
|
||||
...prev,
|
||||
[type]: base64,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const convertToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (!signatures.fullSignature || !signatures.initials) {
|
||||
alert('Please provide both signature and initials');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
signatureType: activeTab,
|
||||
signatureBase64: signatures.fullSignature,
|
||||
initialsBase64: signatures.initials,
|
||||
firstName: fullName.split(' ')[0] || user.firstName,
|
||||
lastName: fullName.split(' ')[1] || user.lastName,
|
||||
font: selectedSignature.style
|
||||
};
|
||||
|
||||
let res;
|
||||
if (isEditMode && editingSignature) {
|
||||
// Update existing signature
|
||||
const updatePayload = {
|
||||
...payload,
|
||||
signatureId: editingSignature.id
|
||||
};
|
||||
res = await api.put(`/api/user/signatures`, updatePayload);
|
||||
console.log("handleUpdate", res);
|
||||
|
||||
toast.success("Signature Updated Successfully", {
|
||||
position: "top-center",
|
||||
autoClose: 500,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: false,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
theme: "light",
|
||||
transition: Bounce,
|
||||
});
|
||||
} else {
|
||||
// Create new signature
|
||||
res = await api.post(`/api/user/signatures`, payload);
|
||||
console.log("handleSubmit", res);
|
||||
|
||||
toast.success("Signature Created Successfully", {
|
||||
position: "top-center",
|
||||
autoClose: 500,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: false,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
theme: "light",
|
||||
transition: Bounce,
|
||||
});
|
||||
}
|
||||
|
||||
if (res?.data) {
|
||||
await getAllSignatures();
|
||||
setShowAddSignature?.(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting signature:", error);
|
||||
toast.error(isEditMode ? "Failed to update signature" : "Failed to create signature", {
|
||||
position: "top-center",
|
||||
autoClose: 2000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: false,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
theme: "light",
|
||||
transition: Bounce,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'SELECT':
|
||||
return (
|
||||
<div className="h-full flex flex-col ">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Choose Signature Style</h2>
|
||||
<p className="text-sm text-gray-600">Select from pre-designed signature styles</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
{signatureOptions.map((option) => (
|
||||
<div key={option.id} className="flex items-center space-x-4 px-2 py-1 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
id={`signature-${option.id}`}
|
||||
name="signature"
|
||||
checked={selectedSignature.id === option.id}
|
||||
onChange={() => setSelectedSignature({ id: option.id, style: option.style })}
|
||||
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<div className=" border-gray-300 rounded-lg px-2 py-1 bg-white ">
|
||||
<div className="text-xs text-gray-500 mb-2">Signed by:</div>
|
||||
<div className={`text-lg ${option.style === 'cursive' ? 'font-serif italic' :
|
||||
option.style === 'script' ? 'font-mono' :
|
||||
option.style === 'elegant' ? 'font-serif' :
|
||||
option.style === 'modern' ? 'font-sans tracking-wide' :
|
||||
'font-sans'
|
||||
}`}>
|
||||
{fullName || option.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
dca3e646-62cd-4cc...
|
||||
</div>
|
||||
</div>
|
||||
<div className=" rounded-lg px-2 py-2 bg-white ">
|
||||
<div className="text-xs text-gray-500 mb-2">DS</div>
|
||||
<div className={`text-2xl ${option.style === 'cursive' ? 'font-serif italic' :
|
||||
option.style === 'script' ? 'font-mono' :
|
||||
option.style === 'elegant' ? 'font-serif' :
|
||||
option.style === 'modern' ? 'font-sans tracking-wide' :
|
||||
'font-sans'
|
||||
}`}>
|
||||
{initials || option.initials}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'DRAW':
|
||||
return (
|
||||
<DrawSignatureCanvas
|
||||
onSignatureChange={handleSignatureChange}
|
||||
initialSignatures={isEditMode ? signatures : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'UPLOAD':
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Upload Signature</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Upload 1 */}
|
||||
<div className="col-span-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Full Signature</label>
|
||||
<label htmlFor="upload1" className=" border-2 border-dashed border-gray-300 rounded-lg h-62 flex items-center justify-center bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors">
|
||||
{preview1 ? (
|
||||
<img src={preview1} alt="Signature Preview" className="max-h-44 object-contain" />
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<MdCloudUpload className="mx-auto h-12 w-12 text-gray-400 mb-3" />
|
||||
<p className="text-sm text-gray-500 mb-1">Click to upload or drag and drop</p>
|
||||
<p className="text-xs text-gray-400">PNG, JPG, GIF up to 10MB</p>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="upload1"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageChange(e, setFile1, setPreview1, "fullSignature")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload 2 */}
|
||||
<div className="col-span-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Initials</label>
|
||||
<label htmlFor="upload2" className=" border-2 border-dashed border-gray-300 rounded-lg h-62 flex items-center justify-center bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors">
|
||||
{preview2 ? (
|
||||
<img src={preview2} alt="Initials Preview" className="max-h-44 object-contain" />
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<MdCloudUpload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-xs text-gray-500">Upload initials</p>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="upload2"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageChange(e, setFile2, setPreview2, "initials")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getUserDetails = async () => {
|
||||
try {
|
||||
let res = await api.get(`/api/user/details`);
|
||||
console.log("getuser", res);
|
||||
let currUser = res?.data;
|
||||
setUser(currUser);
|
||||
|
||||
// Only set default values if not in edit mode
|
||||
if (!isEditMode) {
|
||||
setFullName(currUser.firstName + " " + currUser.lastName);
|
||||
setInitials(currUser.firstName.slice(0, 1) + currUser.lastName.slice(0, 1));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching user details:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getUserDetails();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-md shadow-xl w-full max-w-[80vw] h-[80vh] flex relative">
|
||||
|
||||
{/* Sidebar Navigation */}
|
||||
<div className="w-64 bg-gray-50 rounded-l-lg border-r border-gray-200 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className='px-1 py-1'>
|
||||
<h3 className="text-md font-semibold text-gray-900">Add Signature</h3>
|
||||
<p className="text-xs text-gray-500">Create your digital signature</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Inputs */}
|
||||
<div className="px-6 py-2.5 border-b border-gray-200">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder="Enter full name"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Initials
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={initials}
|
||||
onChange={(e) => setInitials(e.target.value)}
|
||||
placeholder="Enter initials"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<div className="flex-1 px-6 py-2.5">
|
||||
<nav className="space-y-2">
|
||||
{navigationItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id as 'SELECT' | 'DRAW' | 'UPLOAD')}
|
||||
className={`w-full flex items-center space-x-3 px-2 py-2 text-sm font-medium rounded-lg cursor-pointer transition-colors ${activeTab === item.id
|
||||
? 'bg-blue-50 text-blue-700 border border-blue-200'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<span className={activeTab === item.id ? 'text-blue-600' : 'text-gray-400'}>
|
||||
{item.icon}
|
||||
</span>
|
||||
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col ">
|
||||
{/* Header with close button */}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 py-5 px-5 overflow-hidden ">
|
||||
{/* <button
|
||||
onClick={() => setShowAddSignature?.(false)}
|
||||
className="absolute top-2 right-3 p-2 hover:bg-gray-100 rounded-full transition-colors z-20"
|
||||
>
|
||||
<AiOutlineClose className="w-5 h-5 text-gray-500" />
|
||||
</button> */}
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Footer Buttons in Main Area */}
|
||||
<div className="p-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button
|
||||
className="cursor-pointer px-4 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
onClick={() => setShowAddSignature?.(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
<button
|
||||
className="cursor-pointer px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<FaSpinner className="animate-spin" />
|
||||
{isEditMode ? 'UPDATING...' : 'CREATING...'}
|
||||
</>
|
||||
) : (
|
||||
isEditMode ? 'UPDATE SIGNATURE' : 'CREATE SIGNATURE'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSignatureModal;
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
import React, { useRef, useState, useEffect } from 'react';
|
||||
|
||||
type Props = {
|
||||
onSignatureChange: (data: { fullSignature: string | null; initials: string | null }) => void;
|
||||
initialSignatures?: { fullSignature: string | null; initials: string | null };
|
||||
};
|
||||
|
||||
const DrawSignatureCanvas: React.FC<Props> = ({ onSignatureChange, initialSignatures }) => {
|
||||
const canvasRef1 = useRef<HTMLCanvasElement>(null);
|
||||
const canvasRef2 = useRef<HTMLCanvasElement>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [paths1, setPaths1] = useState<Array<Array<{ x: number; y: number }>>>([]);
|
||||
const [paths2, setPaths2] = useState<Array<Array<{ x: number; y: number }>>>([]);
|
||||
const [currentPath, setCurrentPath] = useState<Array<{ x: number; y: number }>>([]);
|
||||
const [activeCanvas, setActiveCanvas] = useState<'full' | 'initials' | null>(null);
|
||||
const [initialLoaded, setInitialLoaded] = useState(false);
|
||||
|
||||
// Store initial signature images to avoid reloading
|
||||
const [initialImage1, setInitialImage1] = useState<HTMLImageElement | null>(null);
|
||||
const [initialImage2, setInitialImage2] = useState<HTMLImageElement | null>(null);
|
||||
|
||||
// Setup canvas dimensions when component mounts
|
||||
useEffect(() => {
|
||||
const setupCanvas = () => {
|
||||
if (canvasRef1.current) {
|
||||
const rect1 = canvasRef1.current.getBoundingClientRect();
|
||||
canvasRef1.current.width = rect1.width;
|
||||
canvasRef1.current.height = rect1.height;
|
||||
}
|
||||
if (canvasRef2.current) {
|
||||
const rect2 = canvasRef2.current.getBoundingClientRect();
|
||||
canvasRef2.current.width = rect2.width;
|
||||
canvasRef2.current.height = rect2.height;
|
||||
}
|
||||
};
|
||||
|
||||
setupCanvas();
|
||||
|
||||
// Load initial signatures after canvas setup
|
||||
if (initialSignatures && !initialLoaded) {
|
||||
setTimeout(() => {
|
||||
loadInitialSignatures();
|
||||
}, 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadInitialSignatures = () => {
|
||||
if (!initialSignatures) return;
|
||||
|
||||
let loadedCount = 0;
|
||||
const totalToLoad = (initialSignatures.fullSignature ? 1 : 0) + (initialSignatures.initials ? 1 : 0);
|
||||
|
||||
const checkComplete = () => {
|
||||
loadedCount++;
|
||||
if (loadedCount === totalToLoad) {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Load full signature
|
||||
if (initialSignatures.fullSignature && canvasRef1.current) {
|
||||
const img1 = new Image();
|
||||
img1.onload = () => {
|
||||
setInitialImage1(img1);
|
||||
const ctx1 = canvasRef1.current?.getContext('2d');
|
||||
if (ctx1) {
|
||||
ctx1.clearRect(0, 0, canvasRef1.current!.width, canvasRef1.current!.height);
|
||||
ctx1.drawImage(img1, 0, 0);
|
||||
}
|
||||
checkComplete();
|
||||
};
|
||||
img1.src = initialSignatures.fullSignature;
|
||||
}
|
||||
|
||||
// Load initials
|
||||
if (initialSignatures.initials && canvasRef2.current) {
|
||||
const img2 = new Image();
|
||||
img2.onload = () => {
|
||||
setInitialImage2(img2);
|
||||
const ctx2 = canvasRef2.current?.getContext('2d');
|
||||
if (ctx2) {
|
||||
ctx2.clearRect(0, 0, canvasRef2.current!.width, canvasRef2.current!.height);
|
||||
ctx2.drawImage(img2, 0, 0);
|
||||
}
|
||||
checkComplete();
|
||||
};
|
||||
img2.src = initialSignatures.initials;
|
||||
}
|
||||
|
||||
if (totalToLoad === 0) {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
setIsDrawing(true);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const point = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
setCurrentPath([point]);
|
||||
setActiveCanvas(e.currentTarget === canvasRef1.current ? 'full' : 'initials');
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const point = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
const newPath = [...currentPath, point];
|
||||
setCurrentPath(newPath);
|
||||
|
||||
// Draw immediately for smooth experience
|
||||
drawCurrentPath(e.currentTarget as HTMLCanvasElement, newPath);
|
||||
};
|
||||
|
||||
const drawCurrentPath = (canvas: HTMLCanvasElement, path: Array<{ x: number; y: number }>) => {
|
||||
if (!canvas || path.length < 2) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Only draw the current path segment without clearing the canvas
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
const lastPoint = path[path.length - 2];
|
||||
const currentPoint = path[path.length - 1];
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastPoint.x, lastPoint.y);
|
||||
ctx.lineTo(currentPoint.x, currentPoint.y);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const exportCanvasAsImage = (canvas: HTMLCanvasElement | null): string | null => {
|
||||
if (!canvas) return null;
|
||||
return canvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDrawing(false);
|
||||
if (currentPath.length === 0 || !activeCanvas) return;
|
||||
|
||||
if (activeCanvas === 'full') {
|
||||
const newPaths = [...paths1, currentPath];
|
||||
setPaths1(newPaths);
|
||||
} else {
|
||||
const newPaths = [...paths2, currentPath];
|
||||
setPaths2(newPaths);
|
||||
}
|
||||
|
||||
setCurrentPath([]);
|
||||
|
||||
// Export both canvases and notify parent
|
||||
const fullSignature = exportCanvasAsImage(canvasRef1.current);
|
||||
const initials = exportCanvasAsImage(canvasRef2.current);
|
||||
onSignatureChange({ fullSignature, initials });
|
||||
};
|
||||
|
||||
const redrawCanvas = (canvas: HTMLCanvasElement, paths: Array<Array<{ x: number; y: number }>>, initialImage: HTMLImageElement | null) => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw initial signature if exists
|
||||
if (initialImage) {
|
||||
ctx.drawImage(initialImage, 0, 0);
|
||||
}
|
||||
|
||||
// Draw all paths
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
paths.forEach((path) => {
|
||||
if (path.length < 2) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(path[0].x, path[0].y);
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
ctx.lineTo(path[i].x, path[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
});
|
||||
};
|
||||
|
||||
const clearCanvas = () => {
|
||||
if (canvasRef1.current) {
|
||||
const ctx1 = canvasRef1.current.getContext('2d');
|
||||
ctx1?.clearRect(0, 0, canvasRef1.current.width, canvasRef1.current.height);
|
||||
}
|
||||
if (canvasRef2.current) {
|
||||
const ctx2 = canvasRef2.current.getContext('2d');
|
||||
ctx2?.clearRect(0, 0, canvasRef2.current.width, canvasRef2.current.height);
|
||||
}
|
||||
|
||||
setPaths1([]);
|
||||
setPaths2([]);
|
||||
setInitialImage1(null);
|
||||
setInitialImage2(null);
|
||||
setInitialLoaded(false);
|
||||
onSignatureChange({ fullSignature: null, initials: null });
|
||||
};
|
||||
|
||||
const undoLastPath = () => {
|
||||
if (activeCanvas === 'full' && paths1.length > 0) {
|
||||
const newPaths = paths1.slice(0, -1);
|
||||
setPaths1(newPaths);
|
||||
redrawCanvas(canvasRef1.current!, newPaths, initialImage1);
|
||||
} else if (activeCanvas === 'initials' && paths2.length > 0) {
|
||||
const newPaths = paths2.slice(0, -1);
|
||||
setPaths2(newPaths);
|
||||
redrawCanvas(canvasRef2.current!, newPaths, initialImage2);
|
||||
}
|
||||
|
||||
// Update parent with new signature data
|
||||
setTimeout(() => {
|
||||
const fullSignature = exportCanvasAsImage(canvasRef1.current);
|
||||
const initials = exportCanvasAsImage(canvasRef2.current);
|
||||
onSignatureChange({ fullSignature, initials });
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Draw Signature</h2>
|
||||
<p className="text-sm text-gray-600">Create your signature by drawing directly on the canvas</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="grid grid-cols-12 gap-6 mb-2">
|
||||
{/* Full Signature Canvas */}
|
||||
<div className="col-span-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Full Signature</label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<canvas
|
||||
ref={canvasRef1}
|
||||
className="w-full h-48 cursor-crosshair"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Initials Canvas */}
|
||||
<div className="col-span-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Initials</label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<canvas
|
||||
ref={canvasRef2}
|
||||
className="w-full h-48 cursor-crosshair"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
onClick={clearCanvas}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
onClick={undoLastPath}
|
||||
disabled={!activeCanvas || (activeCanvas === 'full' ? paths1.length === 0 : paths2.length === 0)}
|
||||
>
|
||||
Undo Last
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawSignatureCanvas;
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { FiEdit3, FiTrash2, FiPlus, FiMoreVertical } from 'react-icons/fi';
|
||||
import AddSignatureModal from './AddSignatureModal';
|
||||
import api from '../../../config/api';
|
||||
import { Bounce, toast } from "react-toastify";
|
||||
import { FaSpinner } from 'react-icons/fa';
|
||||
|
||||
interface Signature {
|
||||
id: string;
|
||||
email: string;
|
||||
signatureType: string;
|
||||
lastName: string;
|
||||
firstName: string
|
||||
font: string;
|
||||
initialsBlob: string;
|
||||
initialsStampedBlob: string;
|
||||
initialsHash: string;
|
||||
signatureHash: string;
|
||||
signatureBlob: string;
|
||||
signatureStampedBlob: string;
|
||||
default: boolean
|
||||
}
|
||||
|
||||
const SignatureManager: React.FC = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [signatures, setSignatures] = useState<Signature[]>([]);
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
||||
const [showAddSignature, setShowAddSignature] = useState<boolean>(false);
|
||||
const [editingSignature, setEditingSignature] = useState<Signature | null>(null);
|
||||
|
||||
const getAllSignatures = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
let res = await api.get("/api/user/signatures")
|
||||
console.log("getAllSignatures", res.data)
|
||||
let signatureData = res?.data
|
||||
setSignatures(signatureData)
|
||||
} catch (error) {
|
||||
console.log("getAllSignatures", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getAllSignatures()
|
||||
}, [])
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
console.log('Edit signature:', id);
|
||||
let editSignature = signatures.find((item) => item.id === id);
|
||||
console.log("editSignature", editSignature);
|
||||
|
||||
if (editSignature) {
|
||||
setEditingSignature(editSignature);
|
||||
setShowAddSignature(true);
|
||||
}
|
||||
setActiveDropdown(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!id) return;
|
||||
setLoading(true)
|
||||
try {
|
||||
let res = await api.delete(`/api/user/signatures/${id}`)
|
||||
console.log(res.data)
|
||||
toast.success("Signature Delete Successfull", {
|
||||
position: "top-center",
|
||||
autoClose: 500,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: false,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
theme: "light",
|
||||
transition: Bounce,
|
||||
});
|
||||
await getAllSignatures();
|
||||
} catch (error) {
|
||||
console.log("delete signature", error)
|
||||
} finally {
|
||||
setActiveDropdown(null);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDropdown = (id: string) => {
|
||||
setActiveDropdown(activeDropdown === id ? null : id);
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
setEditingSignature(null);
|
||||
setShowAddSignature(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setShowAddSignature(false);
|
||||
setEditingSignature(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? <div className=' flex items-center justify-center'>
|
||||
<div className="p-12 text-center mt-8">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<FaSpinner className="w-20 h-20 mx-auto animate-spin" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">Loading...</h3>
|
||||
</div>
|
||||
</div> :
|
||||
<div className="min-h-screen bg-white p-3 rounded-md px-4 mb-6">
|
||||
<div className="max-w-[84vw] mx-auto">
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-[17px] font-medium text-gray-500 mb-2">Signatures</h1>
|
||||
<p className="text-gray-300 text-sm">Add or update your name and signature styles.</p>
|
||||
</div>
|
||||
<button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-semibold text-sm text-[14px] flex items-center cursor-pointer transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" onClick={handleAddNew}>
|
||||
<FiPlus className="" />
|
||||
ADD
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table Card */}
|
||||
<div className="bg-white rounded-md shadow-md overflow-hidden border border-gray-100">
|
||||
{/* Table Header */}
|
||||
<div className="bg-gray-50 border-b border-gray-200">
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-4">
|
||||
<div className="col-span-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">Signature</h3>
|
||||
</div>
|
||||
<div className="col-span-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">Initial</h3>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider text-right">Actions</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-gray-100">
|
||||
{signatures.map((signature, index) => (
|
||||
<div key={signature.id} className="grid grid-cols-12 gap-4 px-6 py-9 hover:bg-gray-50 transition-colors duration-150">
|
||||
{/* Signature Column */}
|
||||
<div className="col-span-5">
|
||||
<div className="flex flex-col space-y-2 relative ps-6 border-l-3 border-blue-600 rounded-bl-2xl rounded-tl-2xl">
|
||||
{/* Label */}
|
||||
<div className="text-sm text-gray-600 font-medium mb-1">
|
||||
Signed by:
|
||||
</div>
|
||||
|
||||
{/* Signature Image */}
|
||||
<div className="bg-white rounded-lg p-1 flex items-center justify-center ">
|
||||
<img
|
||||
src={`data:image/png;base64,${signature.signatureBlob}`}
|
||||
className="h-[50px] w-[250px] object-contain"
|
||||
alt="Signature"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Signature ID */}
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{signature.signatureHash}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Initial Column */}
|
||||
<div className="col-span-5">
|
||||
<div className="flex flex-col space-y-2 relative ps-6 border-l-3 border-green-600 rounded-bl-2xl rounded-tl-2xl">
|
||||
{/* Label */}
|
||||
<div className="text-sm text-gray-600 font-medium mb-1">
|
||||
DS
|
||||
</div>
|
||||
|
||||
{/* Signature Image */}
|
||||
<div className="bg-white rounded-lg p-1 flex items-center justify-center ">
|
||||
<img
|
||||
src={`data:image/png;base64,${signature.initialsBlob}`}
|
||||
className="h-[50px] object-contain"
|
||||
alt="DS Signature"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Column */}
|
||||
<div className="col-span-2 flex justify-between items-start pt-9 ps-8 ">
|
||||
<div className="relative ">
|
||||
<button
|
||||
onClick={() => toggleDropdown(signature.id)}
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-1.5 py-1.5 text-[13px] rounded-lg font-semibold flex items-center transition-all duration-200 border border-gray-300 "
|
||||
>
|
||||
ACTIONS
|
||||
<FiMoreVertical />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{activeDropdown === signature.id && (
|
||||
<div className="absolute right-0 mt-0.5 w-28 bg-white rounded-lg shadow-xl border border-gray-200 z-10 py-1">
|
||||
<button
|
||||
onClick={() => handleEdit(signature.id)}
|
||||
className="w-full px-4 py-1 text-left text-gray-500 hover:bg-blue-50 hover:text-blue-700 flex items-center gap-3 text-sm transition-colors duration-150"
|
||||
>
|
||||
<FiEdit3 />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(signature.id)}
|
||||
className="w-full px-4 py-1 text-left text-gray-500 hover:bg-red-50 hover:text-red-700 flex items-center gap-3 text-sm transition-colors duration-150"
|
||||
>
|
||||
<FiTrash2 />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty State (if no signatures) */}
|
||||
{signatures.length === 0 && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-12 text-center mt-8">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<FiEdit3 className="w-16 h-16 mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">No signatures yet</h3>
|
||||
<p className="text-gray-500 mb-6">Create your first signature to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* Click outside to close dropdown */}
|
||||
{activeDropdown && (
|
||||
<div
|
||||
className="fixed inset-0 z-0"
|
||||
onClick={() => setActiveDropdown(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAddSignature && (
|
||||
<AddSignatureModal
|
||||
setShowAddSignature={handleModalClose}
|
||||
getAllSignatures={getAllSignatures}
|
||||
editingSignature={editingSignature}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureManager;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { FiX, FiEdit2 } from 'react-icons/fi';
|
||||
interface Account {
|
||||
id: number;
|
||||
isDefault: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
language: string;
|
||||
account: Account;
|
||||
}
|
||||
|
||||
interface UserProfileProps {
|
||||
userData: UserData;
|
||||
}
|
||||
const UserProfile: React.FC<UserProfileProps> = ({ userData }) => {
|
||||
const getInitials = (firstName: string, lastName: string) => {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
};
|
||||
return (
|
||||
<div className="bg-white rounded-md shadow-sm flex w-full p-6">
|
||||
{/* Profile Picture */}
|
||||
<div className="flex-shrink-0 text-center">
|
||||
<div className="w-20 h-20 bg-green-800 text-white font-bold text-2xl rounded-full flex items-center justify-center shadow-lg">
|
||||
{getInitials(userData.firstName, userData.lastName)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Fields */}
|
||||
<div className="flex flex-col justify-center flex-1 ms-10">
|
||||
{/* Row: Name */}
|
||||
<div className="flex py-1">
|
||||
<div className="text-gray-500 font-medium text-[13px] w-32">Name</div>
|
||||
<div className="text-gray-400 font-medium text-[13px]">
|
||||
{userData.firstName} {userData.lastName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row: Email */}
|
||||
<div className="flex py-1">
|
||||
<div className="text-gray-500 font-medium text-[13px] w-32">Email</div>
|
||||
<div className="text-gray-400 font-medium text-[13px]">{userData.email}</div>
|
||||
</div>
|
||||
|
||||
{/* Row: Account Name */}
|
||||
<div className="flex py-1">
|
||||
<div className="text-gray-500 font-medium text-[13px] w-32">Account name</div>
|
||||
<div className="text-gray-400 font-medium text-[13px]">
|
||||
{userData.account.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import { AiFillSignature } from 'react-icons/ai';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { LuGitGraph } from 'react-icons/lu';
|
||||
import AccountNav from '../accounts/AccountNav';
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../config/api';
|
||||
import SigningOrderDiagram from '../../SigningOrderDiagram ';
|
||||
import { IoCloseOutline } from 'react-icons/io5';
|
||||
import { MdEditDocument } from "react-icons/md";
|
||||
|
||||
// Sample recipient data — you can replace this with actual API data later
|
||||
const recipients = [
|
||||
|
|
@ -27,6 +30,8 @@ const recipients = [
|
|||
|
||||
const TransactionView = () => {
|
||||
const { accountId, transactionId } = useParams<{ accountId: string, transactionId: string }>();
|
||||
console.log("account" , accountId , transactionId)
|
||||
|
||||
|
||||
if (!accountId || !transactionId) {
|
||||
window.location.href = "/";
|
||||
|
|
@ -34,6 +39,8 @@ const TransactionView = () => {
|
|||
}
|
||||
|
||||
const [transactionDetails, setTransactionDetails] = useState(null);
|
||||
const [signingOrderModal, setSigningOrderModal] = useState({ open: false });
|
||||
console.log(transactionDetails, "line 37");
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
|
|
@ -57,7 +64,7 @@ const TransactionView = () => {
|
|||
<div className="w-full px-6 py-4 rounded-tl-sm text-xs border-r-2 border-gray-200 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<h1 className='text-lg font-semibold text-gray-800'>
|
||||
Complete with Forms Made Easy: <span className='text-slate-600'>{transactionDetails.transaction.name ? transactionDetails.transaction.name : "Untitled"}</span>
|
||||
Complete with Forms Made Easy: <span className='text-slate-600'>{ transactionDetails.transaction.name ?? "Untitled"}</span>
|
||||
</h1>
|
||||
|
||||
<div className='mt-2'>
|
||||
|
|
@ -77,18 +84,23 @@ const TransactionView = () => {
|
|||
>
|
||||
Sign
|
||||
</button> */}
|
||||
<button className='cursor-pointer border border-gray-300 rounded-sm px-4 py-1 hover:bg-gray-100'>
|
||||
<button className='cursor-pointer border border-gray-300 rounded-sm px-4 py-1 hover:bg-gray-100 font-bold'>
|
||||
More
|
||||
</button>
|
||||
|
||||
<Link className='cursor-pointer border text-green-700 border-gray-300 rounded-sm px-4 py-1 hover:bg-gray-100 font-bold' to={`/process-step-two/${accountId}/${transactionId}`}>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<hr className='my-6 border-slate-200' />
|
||||
|
||||
{/* Recipients Section */}
|
||||
{transactionDetails.recipients.length > 0 &&
|
||||
<div>
|
||||
<div className='flex justify-between items-center text-md'>
|
||||
<h2 className='text-lg font-semibold text-gray-800 cursor-pointer'>Signers</h2>
|
||||
<p className='font-bold flex items-center gap-1.5 text-slate-800 cursor-pointer'>
|
||||
<p className='font-bold flex items-center gap-1.5 text-slate-800 cursor-pointer' onClick={() => setSigningOrderModal({ ...signingOrderModal, open: true })}>
|
||||
<LuGitGraph className='text-[0.9rem] text-[var(--primary)]' />
|
||||
Signing Graph
|
||||
</p>
|
||||
|
|
@ -122,6 +134,7 @@ const TransactionView = () => {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
<div>
|
||||
|
|
@ -137,7 +150,11 @@ const TransactionView = () => {
|
|||
<div className='border-3 rounded-sm border-slate-300 relative text-xs'>
|
||||
<img style={{ width: "200px", height: "160px" }} src="https://images.unsplash.com/photo-1746469535771-71a672e8719f?q=80&w=870&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" alt="" />
|
||||
<div className='bg-slate-100 p-2 absolute w-full bottom-[0px]'>
|
||||
<p className='text-[0.7rem] text-slate-500'>1 of 10</p>
|
||||
<p className='text-[0.7rem] text-slate-500 flex justify-between items-center'>
|
||||
<span> 1 of 10</span>
|
||||
<span className='cursor-pointer'><Link to={`/process-step-two/${accountId}/${transactionId}`}><MdEditDocument size={16}/></Link></span>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -146,6 +163,24 @@ const TransactionView = () => {
|
|||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{signingOrderModal.open &&
|
||||
<div className="fixed top-0 left-0 inset-0 z-50 bg-black/50 backdrop-blur-sm flex justify-center items-center overflow-hidden">
|
||||
<div className="flex-col relative justify-center items-center max-w-[80%] max-h-[60vh] h-full ">
|
||||
<div className="pb-2 text-white flex justify-between items-center bg-[var(--primary)] px-2 py-3 rounded-t-lg">
|
||||
<p className="font-semibold">Signing Order</p>
|
||||
<div
|
||||
onClick={() => setSigningOrderModal({ ...signingOrderModal, open: false })}
|
||||
className="text-xl cursor-pointer hover:text-gray-200 transition-all">
|
||||
<IoCloseOutline />
|
||||
</div>
|
||||
</div>
|
||||
<span className='overflow-y-auto'>
|
||||
<SigningOrderDiagram signers={transactionDetails.recipients} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@
|
|||
--hideModalBg: #2d2d2d;
|
||||
|
||||
--processStepOneTextHead: #000000;
|
||||
|
||||
--modalSubmitBG:#1919d7;
|
||||
--modalSubmitBGHover:#395ee1;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -74,3 +77,15 @@ canvas {
|
|||
.animate-text {
|
||||
animation: textColorChange 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue