diff --git a/package-lock.json b/package-lock.json
index ea79ccf..f47863f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"dotenv": "^16.4.7",
"ejs": "^3.1.10",
"express": "^4.19.2",
+ "express-ejs-layouts": "^2.5.1",
"express-minify": "^1.0.0",
"helmet": "^8.1.0",
"html-minifier": "^4.0.0",
@@ -148,6 +149,7 @@
"integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.0",
@@ -2359,6 +2361,7 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -2903,6 +2906,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@@ -3740,7 +3744,8 @@
"version": "0.0.1452169",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1452169.tgz",
"integrity": "sha512-FOFDVMGrAUNp0dDKsAU1TorWJUx2JOU1k9xdgBKKJF3IBh/Uhl2yswG5r3TEAOrCiGY2QRp1e6LVDQrCsTKO4g==",
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "peer": true
},
"node_modules/dom-serializer": {
"version": "0.1.1",
@@ -4231,6 +4236,11 @@
"node": ">= 0.10.0"
}
},
+ "node_modules/express-ejs-layouts": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/express-ejs-layouts/-/express-ejs-layouts-2.5.1.tgz",
+ "integrity": "sha512-IXROv9n3xKga7FowT06n1Qn927JR8ZWDn5Dc9CJQoiiaaDqbhW5PDmWShzbpAa2wjWT1vJqaIM1S6vJwwX11gA=="
+ },
"node_modules/express-minify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/express-minify/-/express-minify-1.0.0.tgz",
@@ -7986,6 +7996,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -8369,6 +8380,7 @@
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.5.tgz",
"integrity": "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"ip-address": "^9.0.5",
"smart-buffer": "^4.2.0"
@@ -9077,6 +9089,7 @@
"integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
@@ -9124,6 +9137,7 @@
"integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.6.1",
"@webpack-cli/configtest": "^3.0.1",
@@ -9289,6 +9303,7 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",
diff --git a/package.json b/package.json
index 846e8c2..acfe04f 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"scripts": {
"start": "nodemon --inspect src/server.js",
"test": "echo \"Error: no test specified\" && exit 1",
+ "deploy": "curl -X POST http://35.162.222.18:9000/hooks/deploy-va-middleware",
"build": "npx webpack --mode production",
"dev": "npx webpack --mode development"
},
@@ -24,6 +25,7 @@
"dotenv": "^16.4.7",
"ejs": "^3.1.10",
"express": "^4.19.2",
+ "express-ejs-layouts": "^2.5.1",
"express-minify": "^1.0.0",
"helmet": "^8.1.0",
"html-minifier": "^4.0.0",
diff --git a/public/css/dashboard.css b/public/css/dashboard.css
index 62e108d..95139cc 100644
--- a/public/css/dashboard.css
+++ b/public/css/dashboard.css
@@ -15,7 +15,7 @@
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
-
+
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-card: #ffffff;
@@ -213,6 +213,7 @@ body {
transform: translateX(-100%);
transition: .3s ease;
}
+
.dashboard-sidebar.active {
transform: translateX(0);
}
@@ -260,6 +261,54 @@ body {
color: var(--text-primary);
}
+/* ===== FIX DUPLICATE SELECT ICON ISSUE ===== */
+select,
+.select-field {
+ appearance: none !important;
+ -webkit-appearance: none !important;
+ -moz-appearance: none !important;
+
+ background-image: none !important;
+ background-repeat: no-repeat !important;
+ background-position: unset !important;
+}
+
+/* Custom select wrapper */
+.select-wrapper {
+ position: relative;
+ width: 100%;
+}
+
+.void-btn-a {
+ background: tomato;
+ text-decoration: none;
+ color: #ffffff !important;
+ border: none;
+ padding: 0.5rem 1rem;
+ border-radius: 5px;
+ font-weight: bold;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+/* Add spacing for icon */
+.select-wrapper select {
+ width: 100%;
+ padding-right: 44px;
+}
+
+/* Single dropdown icon */
+.select-icon {
+ position: absolute;
+ right: 14px;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ color: #aaa;
+ font-size: 14px;
+}
+
+
[data-theme="dark"] .sidebar-link:hover {
background-color: var(--color-gray-800);
}
@@ -699,6 +748,7 @@ body {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}
+
to {
opacity: 1;
transform: scale(1) translateY(0);
@@ -944,17 +994,17 @@ tr[data-vaid]:hover {
align-items: flex-start;
gap: 0.5rem;
}
-
+
.project-time {
align-self: flex-start;
}
-
+
.detail-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
-
+
.detail-item span {
text-align: left;
}
@@ -1215,9 +1265,17 @@ tr[data-vaid]:hover {
}
@keyframes pulse {
- 0% { opacity: 1; }
- 50% { opacity: 0.5; }
- 100% { opacity: 1; }
+ 0% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.5;
+ }
+
+ 100% {
+ opacity: 1;
+ }
}
.no-vas-message {
@@ -1615,11 +1673,11 @@ tr[data-vaid]:hover {
.form-grid {
grid-template-columns: 1fr;
}
-
+
.modal-actions {
flex-direction: column;
}
-
+
.logout-actions {
flex-direction: column;
}
@@ -1627,41 +1685,49 @@ tr[data-vaid]:hover {
/* Additional Dark Mode Styles for Elements Not Covered */
[data-theme="dark"] {
+
/* Ensure all text colors are properly set */
- h1, h2, h3, h4, h5, h6 {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
color: var(--text-primary);
}
-
- p, span, div {
+
+ p,
+ span,
+ div {
color: var(--text-primary);
}
-
+
/* Form elements */
input::placeholder,
textarea::placeholder {
color: var(--text-muted);
}
-
+
/* Selection color */
::selection {
background: rgba(59, 130, 246, 0.3);
color: var(--text-primary);
}
-
+
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
-
+
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
-
+
::-webkit-scrollbar-thumb {
background: var(--color-gray-600);
border-radius: 4px;
}
-
+
::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-500);
}
@@ -1688,4 +1754,187 @@ tr[data-vaid]:hover {
[data-theme="dark"] .loading {
opacity: 0.5;
+}
+
+.va-action {
+ text-decoration: none;
+ color: #fff;
+ background-color: #2563eb;
+ padding: 0.5rem 1rem;
+ text-align: center;
+ border-radius: 5px;
+ transform: scale(0.96);
+ transition: all 0.2s ease-in-out;
+ cursor: pointer;
+}
+
+.va-info {
+ cursor: pointer;
+}
+
+.va-action:hover {
+ transform: scale(1);
+}
+
+
+/* ================================
+ PROFILE DROPDOWN (GLASS STYLE)
+================================ */
+.header-profile {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.profile-trigger {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+
+ padding: 0.70rem 0.75rem;
+
+ border-radius: 999px;
+
+ cursor: pointer;
+
+ /* NEW */
+ border: 1px solid var(--border-color);
+ background: linear-gradient(135deg, rgba(255, 0, 150, 0.3), rgba(0, 204, 255, 0.3));
+
+ transition:
+ background-color 0.2s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+/* Hover effect */
+.profile-trigger:hover {
+ background-color: rgba(0, 0, 0, 0.06);
+ border-color: var(--primary-color);
+}
+
+/* Active (clicked/open) state */
+.profile-trigger.active {
+ background-color: rgba(0, 123, 255, 0.1);
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
+}
+
+
+[data-theme="dark"] .profile-trigger:hover {
+ background-color: rgba(255, 255, 255, 0.08);
+}
+
+/* Avatar */
+.profile-avatar {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid #3b82f6;
+}
+
+/* Name */
+.profile-name {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text-color, #111827);
+}
+
+[data-theme="dark"] .profile-name {
+ color: #e5e7eb;
+}
+
+.profile-trigger i {
+ font-size: 0.7rem;
+ opacity: 0.7;
+}
+
+/* ================================
+ DROPDOWN MENU
+================================ */
+.profile-menu {
+ position: absolute;
+ top: calc(100% + 0.45rem);
+ right: 0;
+ width: 240px;
+ border-radius: 12px;
+ backdrop-filter: blur(16px);
+ background: rgba(255, 255, 255, 0.85);
+ border: 1px solid var(--border-color);
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
+ display: none;
+ flex-direction: column;
+ overflow: hidden;
+ z-index: 50;
+}
+
+[data-theme="dark"] .profile-menu {
+ background: rgba(30, 30, 30, 0.9);
+}
+
+/* Show menu on hover */
+.profile-menu {
+ display: none;
+}
+
+.profile-menu.active {
+ display: flex;
+}
+
+
+/* Menu items */
+.profile-menu a,
+.logout-btn {
+ padding: 0.75rem 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 0.875rem;
+ color: var(--text-color, #111827);
+ background: none;
+ border: none;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background-color 0.15s ease;
+}
+
+.profile-menu a:hover,
+.logout-btn:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+[data-theme="dark"] .profile-menu a,
+[data-theme="dark"] .logout-btn {
+ color: #e5e7eb;
+}
+
+[data-theme="dark"] .profile-menu a:hover,
+[data-theme="dark"] .logout-btn:hover {
+ background-color: rgba(255, 255, 255, 0.08);
+}
+
+/* Divider */
+.menu-divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: 0.25rem 0;
+}
+
+/* Logout */
+.logout-btn {
+ color: #dc2626;
+}
+
+.logout-btn i {
+ color: #dc2626;
+}
+
+/* ================================
+ RESPONSIVE
+================================ */
+@media (max-width: 768px) {
+ .profile-name {
+ display: none;
+ }
}
\ No newline at end of file
diff --git a/public/images/favicon.ico b/public/images/favicon.ico
new file mode 100644
index 0000000..582df3a
Binary files /dev/null and b/public/images/favicon.ico differ
diff --git a/public/images/favicon.svg b/public/images/favicon.svg
new file mode 100644
index 0000000..feb5a41
--- /dev/null
+++ b/public/images/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/js/common/auth.js b/public/js/common/auth.js
new file mode 100644
index 0000000..825bc31
--- /dev/null
+++ b/public/js/common/auth.js
@@ -0,0 +1,29 @@
+window.getAuthToken = function () {
+ return localStorage.getItem("knoptpq");
+};
+
+window.fetchWithAuth = async function (url, options = {}) {
+ const token = getAuthToken();
+
+ if (!token) {
+ window.location.href = "/client/login";
+ return;
+ }
+
+ const res = await fetch(url, {
+ ...options,
+ headers: {
+ token,
+ "Content-Type": "application/json",
+ ...options.headers,
+ },
+ });
+
+ if (res.status === 401) {
+ localStorage.clear();
+ window.location.href = "/client/login";
+ return;
+ }
+
+ return res;
+};
diff --git a/public/js/common/header.js b/public/js/common/header.js
new file mode 100644
index 0000000..7939feb
--- /dev/null
+++ b/public/js/common/header.js
@@ -0,0 +1,29 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const profileTrigger = document.querySelector('.profile-trigger');
+ const profileMenu = document.querySelector('.profile-menu');
+
+ if (!profileTrigger || !profileMenu) return;
+
+ // Toggle on click
+ profileTrigger.addEventListener('click', (e) => {
+ e.stopPropagation();
+ profileMenu.classList.toggle('active');
+ });
+
+ // Close on outside click
+ document.addEventListener('click', () => {
+ profileMenu.classList.remove('active');
+ });
+
+ // Prevent closing when clicking inside menu
+ profileMenu.addEventListener('click', (e) => {
+ e.stopPropagation();
+ });
+
+ // Close on ESC
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ profileMenu.classList.remove('active');
+ }
+ });
+});
\ No newline at end of file
diff --git a/public/js/common/logout.js b/public/js/common/logout.js
new file mode 100644
index 0000000..76289c7
--- /dev/null
+++ b/public/js/common/logout.js
@@ -0,0 +1,23 @@
+function openLogoutModal() {
+ document.getElementById("logoutModal").style.display = "flex";
+ document.body.style.overflow = "hidden";
+}
+
+function closeLogoutModal() {
+ document.getElementById("logoutModal").style.display = "none";
+ document.body.style.overflow = "auto";
+}
+
+async function handleLogout() {
+ try {
+ await fetchWithAuth("/api/v1/client/logout", { method: "POST" });
+ } finally {
+ localStorage.clear();
+ window.location.href = "/client/login";
+ }
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ document.getElementById("logoutButton")?.addEventListener("click", openLogoutModal);
+ document.getElementById("confirmLogoutBtn")?.addEventListener("click", handleLogout);
+});
diff --git a/public/js/common/sidebar.js b/public/js/common/sidebar.js
new file mode 100644
index 0000000..c6922e5
--- /dev/null
+++ b/public/js/common/sidebar.js
@@ -0,0 +1,90 @@
+const currentPath = window.location.pathname;
+document.querySelectorAll('.nav-button').forEach(link => {
+ if (link.getAttribute('href') === currentPath) {
+ link.classList.add('active');
+ } else {
+ link.classList.remove('active');
+ }
+ });
+
+
+ document.querySelectorAll('.sidebar-link').forEach(link => {
+ if (link.getAttribute('href') === currentPath) {
+ link.classList.add('active');
+ } else {
+ link.classList.remove('active');
+ }
+ });
+
+// Auth helpers
+function getAuthToken() {
+ return localStorage.getItem("knoptpq");
+}
+
+async function fetchWithAuth(url, options = {}) {
+ const token = getAuthToken();
+ if (!token) {
+ window.location.href = "/client/login";
+ return;
+ }
+
+ const res = await fetch(url, {
+ ...options,
+ headers: {
+ "token": token,
+ "Content-Type": "application/json",
+ ...options.headers,
+ }
+ });
+
+ if (res.status === 401) {
+ localStorage.clear();
+ window.location.href = "/client/login";
+ return;
+ }
+
+ return res;
+}
+
+// Logout modal
+function openLogoutModal() {
+ document.getElementById("logoutModal").style.display = "flex";
+ document.body.style.overflow = "hidden";
+}
+
+function closeLogoutModal() {
+ document.getElementById("logoutModal").style.display = "none";
+ document.body.style.overflow = "auto";
+}
+
+// Final logout
+async function handleLogout() {
+ try {
+ await fetchWithAuth("/api/v1/client/logout", { method: "POST" });
+ } catch (e) {
+ console.error("Logout error", e);
+ } finally {
+ localStorage.clear();
+ window.location.href = "/client/login";
+ }
+}
+
+// DOM bindings
+document.addEventListener("DOMContentLoaded", () => {
+
+ // Logout button
+ const logoutBtn = document.getElementById("logoutButton");
+ if (logoutBtn) logoutBtn.addEventListener("click", openLogoutModal);
+
+ // Confirm logout
+ const confirmBtn = document.getElementById("confirmLogoutBtn");
+ if (confirmBtn) confirmBtn.addEventListener("click", handleLogout);
+
+ // Click outside modal
+ const modal = document.getElementById("logoutModal");
+ if (modal) {
+ modal.addEventListener("click", (e) => {
+ if (e.target === modal) closeLogoutModal();
+ });
+ }
+});
diff --git a/public/js/common/theme.js b/public/js/common/theme.js
new file mode 100644
index 0000000..554ff85
--- /dev/null
+++ b/public/js/common/theme.js
@@ -0,0 +1,24 @@
+(function () {
+ const root = document.documentElement;
+
+ function applyTheme(theme) {
+ root.setAttribute("data-theme", theme);
+ localStorage.setItem("theme", theme);
+ }
+
+ function getSystemTheme() {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+ }
+
+ function initTheme() {
+ const savedTheme = localStorage.getItem("theme");
+ applyTheme(savedTheme || getSystemTheme());
+ }
+
+ // expose globally
+ window.setTheme = applyTheme;
+
+ document.addEventListener("DOMContentLoaded", initTheme);
+})();
diff --git a/public/js/pages/dashboard-virtual-assistants.js b/public/js/pages/dashboard-virtual-assistants.js
new file mode 100644
index 0000000..9a702c1
--- /dev/null
+++ b/public/js/pages/dashboard-virtual-assistants.js
@@ -0,0 +1,263 @@
+const API_BASE = "/api/v1/client";
+
+// Format time function
+function formatTime(seconds) {
+ if (!seconds) return 'No Record Found';
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ } else if (minutes > 0) {
+ return `${minutes}m`;
+ } else {
+ return 'Just started';
+ }
+}
+
+const vaModal = document.getElementById('vaModal');
+
+function openVAModal(va) {
+ // Set basic VA info
+ document.getElementById('modalVAName').textContent = va.name;
+
+ // Set projects
+ const projects = va.projects || [];
+ document.getElementById('projectsCountBadge').textContent = projects.length;
+
+ const projectsTableBody = document.getElementById('modalProjectsTableBody');
+ const noProjectsMessage = document.getElementById('modalNoProjects');
+
+ if (projects.length > 0) {
+ projectsTableBody.innerHTML = projects.map(project => createProjectTableRow(project)).join('');
+ projectsTableBody.style.display = '';
+ noProjectsMessage.style.display = 'none';
+ } else {
+ projectsTableBody.innerHTML = '';
+ projectsTableBody.style.display = 'none';
+ noProjectsMessage.style.display = 'block';
+ }
+
+ // Set action button
+ const modalActions = document.getElementById('modalActions');
+ const viewSessionBtn = document.getElementById('viewSessionBtn');
+
+ if (va.isCurrentlyActive && va.lastProjectId) {
+ viewSessionBtn.href = `/live/${va.lastProjectId}/${va.id}`;
+ modalActions.style.display = 'block';
+ } else {
+ modalActions.style.display = 'none';
+ }
+
+ // Show modal
+ vaModal.style.display = 'flex';
+ document.body.style.overflow = 'hidden';
+}
+
+function closeVAModal() {
+ vaModal.style.display = 'none';
+ document.body.style.overflow = 'auto';
+}
+
+// Create project table row for modal
+function createProjectTableRow(project) {
+ const statusClass = project.isCurrentlyWorking ? 'active' : 'inactive';
+ const statusText = project.isCurrentlyWorking ? 'Working' : 'Not Active';
+
+ return `
+
+ |
+
+ |
+
+ $${project.hourlyRate}/hr
+ |
+
+ ${project.totalWorkedTime}
+ |
+
+
+ ${statusText}
+
+ |
+
+ `;
+}
+
+// Create VA table row for main table
+function createVATableRow(va) {
+ const statusClass = va.isCurrentlyActive ? 'active' : 'inactive';
+ const statusText = va.isCurrentlyActive ? 'Active' : 'Inactive';
+ const activeProject = va?.lastProject?.name ? va.lastProject.name : '-';
+ const workedTime = formatTime(va.currentSessionDuration);
+ const projectsAssigned = va.projectsAssigned || 0;
+ const viewLink = va.isCurrentlyActive ? `Live Tracking` : 'Logs
';
+
+ return `
+
+ |
+
+ ${statusText}
+
+ |
+
+
+ ${va.name}
+ ${va.email || 'N/A'}
+
+ |
+
+ ${activeProject}
+ |
+
+ ${projectsAssigned} Projects
+ |
+
+ ${workedTime}
+ |
+
+ ${viewLink}
+ |
+
+ `;
+}
+
+// Load dashboard data
+async function loadDashboardData() {
+ try {
+ const response = await fetchWithAuth(`${API_BASE}/dashboard`);
+ if (!response) return;
+ const result = await response.json();
+
+ if (result.success) {
+ const data = result.data;
+ const summary = result.summary;
+
+ // Update stats cards
+ document.getElementById('activeVaCount').textContent = summary.activeVAs || 0;
+ document.getElementById('totalVaCount').textContent = summary.totalVAs || 0;
+ document.getElementById('totalProjects').textContent = summary.totalProjects || 0;
+
+ // Update VA list count
+ document.getElementById('vaListCount').textContent = `${summary.totalVAs || 0} VAs`;
+
+ // Update VA table
+ const vaTableBody = document.getElementById('vaTableBody');
+ const vaEmptyState = document.getElementById('vaEmptyState');
+ const allVAs = data.allVAs || [];
+
+ if (allVAs.length > 0) {
+ vaTableBody.innerHTML = allVAs.map(va => createVATableRow(va)).join('');
+ vaTableBody.style.display = '';
+ vaEmptyState.style.display = 'none';
+
+ // Store VA data globally for modal access
+ window.vaData = allVAs;
+
+ // Add click event listeners to VA rows
+ document.querySelectorAll('.va-row').forEach((row, index) => {
+ row.addEventListener('click', function (e) {
+ // Don't open modal if clicking on view link
+ if (!e.target.closest('.view-link')) {
+ const va = allVAs[index];
+ openVAModal(va);
+ }
+ });
+ });
+ } else {
+ vaTableBody.innerHTML = '';
+ vaTableBody.style.display = 'none';
+ vaEmptyState.style.display = 'block';
+ }
+
+ } else {
+ console.error('Failed to load dashboard data:', result.message);
+ showError('Failed to load dashboard data');
+ }
+
+ } catch (error) {
+ console.error('Error loading dashboard data:', error);
+ showError('Error loading dashboard data');
+ }
+}
+
+// Show error message
+function showError(message) {
+ const vaTableBody = document.getElementById('vaTableBody');
+ const vaEmptyState = document.getElementById('vaEmptyState');
+
+ vaTableBody.style.display = 'none';
+ vaEmptyState.innerHTML = `
+
+ `;
+ vaEmptyState.style.display = 'block';
+}
+
+// Close modal when clicking outside
+vaModal.addEventListener('click', (e) => {
+ if (e.target === vaModal) {
+ closeVAModal();
+ }
+});
+
+// Close modal with escape key
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && vaModal.style.display === 'flex') {
+ closeVAModal();
+ }
+});
+
+// Optional: Auto-refresh data every 30 seconds
+setInterval(loadDashboardData, 30000);
+
+// Logout Modal Functions
+function openLogoutModal() {
+ const modal = document.getElementById('logoutModal');
+ modal.style.display = 'flex';
+ document.body.style.overflow = 'hidden';
+}
+
+function closeLogoutModal() {
+ const modal = document.getElementById('logoutModal');
+ modal.style.display = 'none';
+ document.body.style.overflow = 'auto';
+}
+
+document.addEventListener('DOMContentLoaded', function () {
+ // Load dashboard data
+ loadDashboardData();
+
+ // Mobile menu toggle
+ const menuToggle = document.querySelector('.mobile-menu-button');
+ const sidebar = document.querySelector('.dashboard-sidebar');
+ menuToggle.addEventListener('click', () => {
+ sidebar.classList.toggle('active');
+ });
+
+ // Close modal with escape key
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') {
+ closeLogoutModal();
+ }
+ });
+
+ document.getElementById('logoutModal').addEventListener('click', function (e) {
+ if (e.target === this) {
+ closeLogoutModal();
+ }
+ });
+
+ // Logout button - opens confirmation modal instead of directly logging out
+ document.getElementById('logoutButton').addEventListener('click', openLogoutModal);
+
+ // Confirm logout button
+ document.getElementById('confirmLogoutBtn').addEventListener('click', handleLogout);
+});
+
diff --git a/public/js/pages/dashboard.js b/public/js/pages/dashboard.js
new file mode 100644
index 0000000..38ae32c
--- /dev/null
+++ b/public/js/pages/dashboard.js
@@ -0,0 +1,142 @@
+const API_BASE = "/api/v1/client";
+
+/* ------------------ Utils ------------------ */
+
+function formatTime(seconds) {
+ if (!seconds) return "No Record";
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ return h ? `${h}h ${m}m` : `${m}m`;
+}
+
+/* ------------------ DOM Elements ------------------ */
+
+const vaModal = document.getElementById("vaModal");
+const modalVAName = document.getElementById("modalVAName");
+const modalProjectsTableBody = document.getElementById("modalProjectsTableBody");
+const modalNoProjects = document.getElementById("modalNoProjects");
+
+const vaTableBody = document.getElementById("vaTableBody");
+const vaEmptyState = document.getElementById("vaEmptyState");
+
+/* ------------------ Modal Controls ------------------ */
+
+function openVAModal(va) {
+ modalVAName.textContent = va.name || "VA Details";
+
+ const projects = va.projects || [];
+
+ if (projects.length === 0) {
+ modalProjectsTableBody.innerHTML = "";
+ modalNoProjects.style.display = "block";
+ } else {
+ modalNoProjects.style.display = "none";
+ modalProjectsTableBody.innerHTML = projects
+ .map(createProjectRow)
+ .join("");
+ }
+
+ vaModal.style.display = "flex";
+ document.body.style.overflow = "hidden";
+}
+
+function closeVAModal() {
+ vaModal.style.display = "none";
+ document.body.style.overflow = "auto";
+}
+
+/* Outside click close */
+vaModal.addEventListener("click", (e) => {
+ if (e.target === vaModal) closeVAModal();
+});
+
+/* ESC key close */
+document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") closeVAModal();
+});
+
+/* ------------------ Table Builders ------------------ */
+
+function createProjectRow(project) {
+ return `
+
+ | ${project.name} |
+ $${project.hourlyRate}/hr |
+ ${project.totalWorkedTime} |
+ ${project.isCurrentlyWorking ? "Working" : "Inactive"} |
+
+ `;
+}
+
+function createVATableRow(va, index) {
+ return `
+
+ |
+
+ ${va.isCurrentlyActive ? "Active" : "Inactive"}
+
+ |
+ ${va.name} |
+ ${va.lastProject?.name || "-"} |
+ ${va.projectsAssigned || 0} |
+ ${va.currentSessionDuration || "No Record Found"} |
+
+ ${
+ va.isCurrentlyActive
+ ? `Live`
+ : "-"
+ }
+ |
+
+ `;
+}
+
+
+ setInterval(loadDashboardData, 30000);
+
+/* ------------------ Load Dashboard ------------------ */
+
+async function loadDashboardData() {
+ const res = await fetchWithAuth(`${API_BASE}/dashboard`);
+ if (!res) return;
+
+ const result = await res.json();
+ if (!result.success) return;
+
+ const { summary, data } = result;
+
+ document.getElementById("activeVaCount").textContent = summary.activeVAs;
+ document.getElementById("totalVaCount").textContent = summary.totalVAs;
+ document.getElementById("totalProjects").textContent = summary.totalProjects;
+ document.getElementById("vaListCount").textContent = `${summary.totalVAs} VAs`;
+
+ const allVAs = data.allVAs || [];
+
+ if (allVAs.length === 0) {
+ vaTableBody.innerHTML = "";
+ vaEmptyState.style.display = "block";
+ return;
+ }
+
+ vaEmptyState.style.display = "none";
+
+ /* store globally for modal */
+ window.allVAs = allVAs;
+
+ vaTableBody.innerHTML = allVAs
+ .map((va, index) => createVATableRow(va, index))
+ .join("");
+
+ /* Row click handlers */
+ document.querySelectorAll(".va-row").forEach(row => {
+ row.addEventListener("click", (e) => {
+ if (e.target.tagName === "A") return; // ignore Live click
+ const index = row.dataset.index;
+ openVAModal(window.allVAs[index]);
+ });
+ });
+}
+
+/* ------------------ Init ------------------ */
+
+document.addEventListener("DOMContentLoaded", loadDashboardData);
diff --git a/public/js/pages/manage-tips.js b/public/js/pages/manage-tips.js
new file mode 100644
index 0000000..0017a55
--- /dev/null
+++ b/public/js/pages/manage-tips.js
@@ -0,0 +1,248 @@
+
+
+ // API base URL
+ const API_BASE = '/api/v1/client';
+
+
+
+ function openTipModal() {
+ const modal = document.getElementById('tipModal');
+ modal.style.display = 'flex';
+ document.body.style.overflow = 'hidden';
+
+ // Clear form
+ document.getElementById('tipForm').reset();
+ clearErrorMessages();
+ }
+
+ function closeTipModal() {
+ const modal = document.getElementById('tipModal');
+ modal.style.display = 'none';
+ document.body.style.overflow = 'auto';
+
+ // Clear form and errors
+ document.getElementById('tipForm').reset();
+ clearErrorMessages();
+ }
+
+ function clearErrorMessages() {
+ document.getElementById('vaForTipError').textContent = '';
+ document.getElementById('vaTipAmountError').textContent = '';
+ document.getElementById('vaTipDescriptionError').textContent = '';
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+
+ // Mobile menu toggle
+ const menuToggle = document.querySelector('.mobile-menu-button');
+ const sidebar = document.querySelector('.dashboard-sidebar');
+ menuToggle.addEventListener('click', () => {
+ sidebar.classList.toggle('active');
+ });
+
+ document.getElementById('tipVaAddBtn').addEventListener('click', openTipModal);
+
+ document.getElementById('tipModal').addEventListener('click', function (e) {
+ if (e.target === this) {
+ closeTipModal();
+ }
+ });
+
+ // Close modal with escape key
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') {
+ closeLogoutModal();
+ closeTipModal();
+ }
+ });
+
+ document.getElementById('logoutModal').addEventListener('click', function (e) {
+ if (e.target === this) {
+ closeLogoutModal();
+ }
+ });
+
+ // Logout button - opens confirmation modal instead of directly logging out
+ document.getElementById('logoutButton').addEventListener('click', openLogoutModal);
+
+ // Confirm logout button
+ document.getElementById('confirmLogoutBtn').addEventListener('click', handleLogout);
+ });
+
+ // ------------------------------
+ // TIP FORM SUBMISSION HANDLER
+ // ------------------------------
+
+ document.getElementById('tipForm').addEventListener('submit', async function (e) {
+ e.preventDefault();
+
+ // clearErrorMessages();
+
+ const vaId = document.getElementById('vaForTip').value;
+ const amount = document.getElementById('vaTipAmount').value.trim();
+ const description = document.getElementById('vaTipDescription').value.trim();
+
+ let isValid = true;
+
+ // Validation
+ if (!vaId) {
+ document.getElementById('vaForTipError').textContent = "Please select a virtual assistant.";
+ isValid = false;
+ }
+
+ if (!amount || isNaN(amount) || Number(amount) <= 0) {
+ document.getElementById('vaTipAmountError').textContent = "Enter a valid tip amount.";
+ isValid = false;
+ }
+
+ if (!isValid) return;
+
+ // Prepare API body
+ const payload = {
+ vaId,
+ amount: Number(amount),
+ description
+ };
+
+ try {
+ document.getElementById('submitTipBtn').disabled = true;
+ document.getElementById('submitTipBtn').textContent = "Submitting...";
+
+ const response = await fetchWithAuth(`/api/v1/client/tips`, {
+ method: "POST",
+ body: JSON.stringify(payload)
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ alert(data.message || "Failed to submit tip.");
+ return;
+ }
+
+ alert("Tip submitted successfully!");
+ closeTipModal();
+
+ // Optionally refresh page or reload tips list
+ window.location.reload();
+
+ } catch (err) {
+ console.error("Tip submit error:", err);
+ alert("Something went wrong while submitting tip.");
+ } finally {
+ document.getElementById('submitTipBtn').disabled = false;
+ document.getElementById('submitTipBtn').textContent = "Submit Tip";
+ }
+ });
+
+
+ document.addEventListener('DOMContentLoaded', function () {
+ // Load VAs for tip selection
+ async function loadVAs() {
+ try {
+ const response = await fetchWithAuth(`${API_BASE}/dashboard`);
+ const data = await response.json();
+
+ if (!response.ok) {
+ console.error("Failed to load VAs:", data.message);
+ return;
+ }
+
+ const vaSelect = document.getElementById('vaForTip');
+ data.data.allVAs.forEach(va => {
+ const option = document.createElement('option');
+ option.value = va.id;
+ option.textContent = va.name;
+ vaSelect.appendChild(option);
+ });
+
+ } catch (error) {
+ console.error("Error loading VAs:", error);
+ }
+ }
+
+ loadVAs();
+ });
+
+ document.addEventListener('DOMContentLoaded', function () {
+ // Load VAs for tip selection
+ async function loadTips() {
+ try {
+ const response = await fetchWithAuth(`${API_BASE}/tips`);
+
+ if (!response.ok) {
+ console.error("Failed to load VAs:", data.message);
+ return;
+ }
+ const data = await response.json();
+
+ const vaSelect = document.getElementById('vaTipTableBody');
+ data.data.forEach(tip => {
+ const row = document.createElement('tr');
+ const cellVoid = document.createElement('td');
+ const cellStatus = document.createElement('td');
+ const cellName = document.createElement('td');
+ const cellDescription = document.createElement('td');
+ const cellDate = document.createElement('td');
+ const cellAmount = document.createElement('td');
+
+ cellVoid.innerHTML = ``;
+ cellVoid.classList.add('void-btn');
+
+ cellStatus.textContent = tip.status;
+ cellName.textContent = tip.vaName;
+ cellDescription.textContent = tip.description;
+ cellDate.textContent = tip.createdAt ? new Date(tip.createdAt).toLocaleDateString() : '';
+ cellAmount.textContent = `$${parseFloat(tip.amount).toFixed(2)}`;
+
+ row.appendChild(cellVoid);
+ row.appendChild(cellStatus);
+ row.appendChild(cellName);
+ row.appendChild(cellDescription);
+ row.appendChild(cellDate);
+ row.appendChild(cellAmount);
+
+ vaSelect.appendChild(row);
+ });
+
+ document.querySelectorAll('.void-btn-a').forEach(button => {
+ button.addEventListener('click', async function () {
+ const tipId = this.getAttribute('data-id');
+ if (confirm('Are you sure you want to void this tip?')) {
+ try {
+ const response = await fetchWithAuth(`${API_BASE}/tips/${tipId}`, {
+ method: 'DELETE'
+ });
+ const result = await response.json();
+
+ if (!response.ok) {
+ alert(result.message || 'Failed to void tip.');
+ return;
+ }
+
+ alert('Tip voided successfully!');
+ window.location.reload();
+ } catch (error) {
+ console.error('Error voiding tip:', error);
+ alert('Something went wrong while voiding the tip.');
+ }
+ }
+ });
+ });
+
+ } catch (error) {
+ console.error("Error loading VAs:", error);
+ }
+ }
+
+ loadTips();
+
+
+
+
+
+ });
+
+
+
+
diff --git a/public/js/pages/profile.js b/public/js/pages/profile.js
new file mode 100644
index 0000000..24b0ad1
--- /dev/null
+++ b/public/js/pages/profile.js
@@ -0,0 +1,195 @@
+// API base URL
+const API_BASE = '/api/v1/client';
+
+
+// Format empty fields
+function formatEmptyFields() {
+ const inputs = document.querySelectorAll('.form-input');
+ inputs.forEach(input => {
+ if (!input.value.trim()) {
+ input.value = 'Not provided';
+ input.classList.add('empty-field');
+ } else {
+ input.classList.remove('empty-field');
+ }
+ });
+}
+
+// Load profile data
+async function loadProfileData() {
+ try {
+ const response = await fetchWithAuth(`${API_BASE}/profile`);
+ if (!response) return;
+
+ const result = await response.json();
+ if (result.success) {
+ const data = result.data;
+
+ // Populate only personal and address information
+ const fieldsToShow = ['name', 'email', 'position', 'address', 'city', 'state', 'zip'];
+
+ fieldsToShow.forEach(key => {
+ const element = document.getElementById(key);
+ if (element && data[key]) {
+ element.value = data[key];
+ }
+ });
+
+ formatEmptyFields();
+
+ } else {
+ console.error('Failed to load profile data:', result.message);
+ alert('Failed to load profile data');
+ }
+ } catch (error) {
+ console.error('Error loading profile data:', error);
+ alert('Error loading profile data');
+ }
+}
+
+// Password Modal Functions
+function openPasswordModal() {
+ const modal = document.getElementById('passwordModal');
+ modal.style.display = 'flex';
+ document.body.style.overflow = 'hidden';
+
+ // Clear form
+ document.getElementById('passwordForm').reset();
+ clearErrorMessages();
+}
+
+function closePasswordModal() {
+ const modal = document.getElementById('passwordModal');
+ modal.style.display = 'none';
+ document.body.style.overflow = 'auto';
+
+ // Clear form and errors
+ document.getElementById('passwordForm').reset();
+ clearErrorMessages();
+}
+
+function clearErrorMessages() {
+ document.getElementById('currentPasswordError').textContent = '';
+ document.getElementById('newPasswordError').textContent = '';
+ document.getElementById('confirmPasswordError').textContent = '';
+}
+
+// Change password
+async function changePassword(event) {
+ event.preventDefault();
+
+ const currentPassword = document.getElementById('currentPassword').value;
+ const newPassword = document.getElementById('newPassword').value;
+ const confirmPassword = document.getElementById('confirmPassword').value;
+
+ // Reset errors
+ clearErrorMessages();
+
+ let hasError = false;
+
+ if (!currentPassword) {
+ document.getElementById('currentPasswordError').textContent = 'Current password is required';
+ hasError = true;
+ }
+
+ if (!newPassword) {
+ document.getElementById('newPasswordError').textContent = 'New password is required';
+ hasError = true;
+ } else if (newPassword.length < 6) {
+ document.getElementById('newPasswordError').textContent = 'Password must be at least 6 characters';
+ hasError = true;
+ }
+
+ if (!confirmPassword) {
+ document.getElementById('confirmPasswordError').textContent = 'Please confirm your password';
+ hasError = true;
+ } else if (newPassword !== confirmPassword) {
+ document.getElementById('confirmPasswordError').textContent = 'Passwords do not match';
+ hasError = true;
+ }
+
+ if (hasError) return;
+
+ try {
+ const saveBtn = document.getElementById('savePasswordBtn');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Changing Password...';
+
+ const response = await fetchWithAuth(`${API_BASE}/profile`, {
+ method: 'PATCH',
+ body: JSON.stringify({
+ userDetails: {
+ currentPassword,
+ newPassword,
+ confirmPassword
+ }
+ })
+ });
+
+ if (!response) {
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Change Password';
+ return;
+ }
+
+ const result = await response.json();
+ if (result.success) {
+ alert('Password changed successfully!');
+ closePasswordModal();
+ } else {
+ alert(result.message || 'Failed to change password');
+ }
+ } catch (error) {
+ console.error('Error changing password:', error);
+ alert('Error changing password');
+ } finally {
+ const saveBtn = document.getElementById('savePasswordBtn');
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Change Password';
+ }
+}
+
+// Event listeners
+document.addEventListener('DOMContentLoaded', function () {
+ loadProfileData();
+
+ // Change password button
+ document.getElementById('changePasswordBtn').addEventListener('click', openPasswordModal);
+
+ // Password form submission
+ document.getElementById('passwordForm').addEventListener('submit', changePassword);
+
+ // Close modal when clicking outside
+ document.getElementById('passwordModal').addEventListener('click', function (e) {
+ if (e.target === this) {
+ closePasswordModal();
+ }
+ });
+
+ // Close modal with escape key
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') {
+ closePasswordModal();
+ closeLogoutModal();
+ }
+ });
+
+ document.getElementById('logoutModal').addEventListener('click', function (e) {
+ if (e.target === this) {
+ closeLogoutModal();
+ }
+ });
+
+ // Logout button - opens confirmation modal instead of directly logging out
+ document.getElementById('logoutButton').addEventListener('click', openLogoutModal);
+
+ // Confirm logout button
+ document.getElementById('confirmLogoutBtn').addEventListener('click', handleLogout);
+
+ // Mobile menu toggle
+ const menuToggle = document.querySelector('.mobile-menu-button');
+ const sidebar = document.querySelector('.dashboard-sidebar');
+ menuToggle.addEventListener('click', () => {
+ sidebar.classList.toggle('active');
+ });
+});
\ No newline at end of file
diff --git a/public/js/pages/projects.js b/public/js/pages/projects.js
new file mode 100644
index 0000000..d26b0b6
--- /dev/null
+++ b/public/js/pages/projects.js
@@ -0,0 +1,240 @@
+// API base URL
+const API_BASE = '/api/v1/client';
+
+// Modal management
+const projectModal = document.getElementById('projectModal');
+const modalClose = document.getElementById('modalClose');
+const modalProjectName = document.getElementById('modalProjectName');
+const modalVATableBody = document.getElementById('modalVATableBody');
+const modalNoVAs = document.getElementById('modalNoVAs');
+const vaCountBadge = document.getElementById('vaCountBadge');
+
+
+function openProjectModal(project) {
+ // Set project name
+ modalProjectName.textContent = project.name || 'Unnamed Project';
+
+ // Set project info
+ const hourlyRate = project.hourlyRate ? `$${project.hourlyRate}/hr` : 'Not set';
+ const dedicatedHours = project.dedicatedHours || 'Not set';
+ const statusText = project.status === 'ACTIVE' ? 'Active' : 'Inactive';
+ const statusClass = project.status === 'ACTIVE' ? 'active' : 'inactive';
+
+ // Set VA details
+ const assignedVAs = project.assignedVADetails || [];
+ vaCountBadge.textContent = assignedVAs.length;
+
+ if (assignedVAs.length > 0) {
+ modalVATableBody.innerHTML = assignedVAs.map(va => createVATableRow(va)).join('');
+ modalVATableBody.style.display = '';
+ modalNoVAs.style.display = 'none';
+ } else {
+ modalVATableBody.innerHTML = '';
+ modalVATableBody.style.display = 'none';
+ modalNoVAs.style.display = 'block';
+ }
+
+ // Show modal
+ projectModal.style.display = 'block';
+ document.body.style.overflow = 'hidden';
+}
+
+function createVATableRow(va) {
+ const statusClass = va.status === 'FULLTIME' ? 'active' : 'inactive';
+ const statusText = va.status === 'FULLTIME' ? 'Full Time' :
+ va.status === 'PARTTIME' ? 'Part Time' : 'Inactive';
+
+ const activityClass = va.isActive ? 'active' : 'inactive';
+ const activityText = va.isActive ? 'Currently Active' : 'Not Active';
+
+ // Get initials for avatar
+ const initials = (va.firstName?.charAt(0) || '') + (va.lastName?.charAt(0) || '') || 'VA';
+
+ return `
+
+
+
+ ${initials}
+
+ ${va.firstName} ${va.lastName}
+ ${va.email}
+
+
+ |
+
+
+ ${statusText}
+
+ |
+
+
+ |
+
+ `;
+}
+
+function closeProjectModal() {
+ projectModal.style.display = 'none';
+ document.body.style.overflow = 'auto';
+}
+
+// Close modal when clicking outside
+projectModal.addEventListener('click', (e) => {
+ if (e.target === projectModal) {
+ closeProjectModal();
+ }
+});
+
+// Close modal with escape key
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && projectModal.style.display === 'block') {
+ closeProjectModal();
+ }
+});
+
+function createProjectTableRow(project) {
+ const statusClass = project.status === 'ACTIVE' ? 'active' : 'inactive';
+ const statusText = project.status === 'ACTIVE' ? 'Active' : 'Inactive';
+ const hourlyRate = project.hourlyRate ? `$${project.hourlyRate}/hr` : '-';
+ const dedicatedHours = project.dedicatedHours ? `${project.dedicatedHours}` : '-';
+
+ return `
+
+
+
+ ${project.name || 'Unnamed Project'}
+
+ |
+
+ ${hourlyRate}
+ |
+
+ ${project.vaAssigned || 0}
+ |
+
+ ${dedicatedHours}
+ |
+
+ `;
+}
+
+// Load dashboard data
+async function loadDashboardData() {
+ try {
+ const response = await fetchWithAuth(`${API_BASE}/dashboard-projects`);
+ if (!response) return;
+
+ const result = await response.json();
+ if (result.success) {
+ const data = result.data;
+ const counts = result.counts;
+
+ // Update counts
+ document.getElementById('projectsCount').textContent = `${counts.totalProjects || 0} Projects`;
+
+ // Update Projects table
+ const projectsTableBody = document.getElementById('projectsTableBody');
+ const projectsEmptyState = document.getElementById('projectsEmptyState');
+ const projects = data.projects || [];
+
+ if (projects.length > 0) {
+ projectsTableBody.innerHTML = projects.map(project => createProjectTableRow(project)).join('');
+ projectsTableBody.style.display = '';
+ projectsEmptyState.style.display = 'none';
+
+ // Add click event listeners to project rows
+ document.querySelectorAll('.project-row').forEach((row, index) => {
+ row.addEventListener('click', function () {
+ const project = projects[index];
+ openProjectModal(project);
+ });
+ });
+ } else {
+ projectsTableBody.innerHTML = '';
+ projectsTableBody.style.display = 'none';
+ projectsEmptyState.style.display = 'block';
+ }
+
+ } else {
+ console.error('Failed to load dashboard data:', result.message);
+ showError('Failed to load dashboard data');
+ }
+
+ } catch (error) {
+ console.error('Error loading dashboard data:', error);
+ showError('Error loading dashboard data');
+ }
+}
+
+// Show error message
+function showError(message) {
+ const projectsTableBody = document.getElementById('projectsTableBody');
+ const projectsEmptyState = document.getElementById('projectsEmptyState');
+ const vaTableBody = document.getElementById('vaTableBody');
+ const vaEmptyState = document.getElementById('vaEmptyState');
+
+ // Hide tables
+ projectsTableBody.style.display = 'none';
+ vaTableBody.style.display = 'none';
+
+ // Show error states
+ projectsEmptyState.innerHTML = `
+
+ `;
+ projectsEmptyState.style.display = 'block';
+
+ vaEmptyState.innerHTML = `
+
+ `;
+ vaEmptyState.style.display = 'block';
+}
+
+
+// Optional: Auto-refresh data every 30 seconds
+setInterval(loadDashboardData, 30000);
+
+
+
+document.addEventListener('DOMContentLoaded', function () {
+ // Load dashboard data
+ loadDashboardData();
+
+ // Mobile menu toggle
+ const menuToggle = document.querySelector('.mobile-menu-button');
+ const sidebar = document.querySelector('.dashboard-sidebar');
+ menuToggle.addEventListener('click', () => {
+ sidebar.classList.toggle('active');
+ });
+
+ // Close modal with escape key
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') {
+ closeLogoutModal();
+ }
+ });
+
+ document.getElementById('logoutModal').addEventListener('click', function (e) {
+ if (e.target === this) {
+ closeLogoutModal();
+ }
+ });
+
+ // Logout button - opens confirmation modal instead of directly logging out
+ document.getElementById('logoutButton').addEventListener('click', openLogoutModal);
+
+ // Confirm logout button
+ document.getElementById('confirmLogoutBtn').addEventListener('click', handleLogout);
+});
+
+modalClose.addEventListener('click', closeProjectModal);
\ No newline at end of file
diff --git a/src/controllers/client/dashboard/clientDashboardProjects.controller.js b/src/controllers/client/dashboard/clientDashboardProjects.controller.js
index 9344dd2..30a9b7c 100644
--- a/src/controllers/client/dashboard/clientDashboardProjects.controller.js
+++ b/src/controllers/client/dashboard/clientDashboardProjects.controller.js
@@ -34,6 +34,8 @@ async function client_dashboard_projects(req, res) {
// STEP 1: Get client ID from token
const clientUser = await ClientModel.findOne({ "userDetails.email": clientEmail });
+
+ console.log("Line 38" , clientUser)
if (!clientUser) {
return res.status(404).json({ success: false, message: "Client not found" });
}
@@ -58,7 +60,7 @@ async function client_dashboard_projects(req, res) {
// STEP 3: Get all VAs assigned to these projects
const allAssignedVAs = await VAModel.find({
- _id: { $in: allProjectVaIds }
+ _id: { $in: allProjectVaIds.vaId }
})
.select('userDetails.firstName userDetails.lastName userDetails.email status')
.lean()
diff --git a/src/controllers/va/vaAuth/vaLogin.controller.js b/src/controllers/va/vaAuth/vaLogin.controller.js
index cff77bc..7e00699 100644
--- a/src/controllers/va/vaAuth/vaLogin.controller.js
+++ b/src/controllers/va/vaAuth/vaLogin.controller.js
@@ -42,54 +42,54 @@ async function closePreviousSession(vaId, lastLog) {
});
// console.log(`Created logout log: ${logoutLog._id}`);
- try {
- const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
- const sfDataSync = {
- "_id": logoutLog._id,
- "loggedType": "LOGOUT",
- "vaId": vaId,
- "vaEmail": lastLog.vaEmail,
- "projectId": lastLog.projectId,
- "time": logoutLog.time,
- }
+ // try {
+ // const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
+ // const sfDataSync = {
+ // "_id": logoutLog._id,
+ // "loggedType": "LOGOUT",
+ // "vaId": vaId,
+ // "vaEmail": lastLog.vaEmail,
+ // "projectId": lastLog.projectId,
+ // "time": logoutLog.time,
+ // }
- const response = await fetch(salesforceUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- // Add any required headers for Salesforce authentication
- // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
- },
- body: JSON.stringify(sfDataSync)
- });
+ // const response = await fetch(salesforceUrl, {
+ // method: 'POST',
+ // headers: {
+ // 'Content-Type': 'application/json',
+ // // Add any required headers for Salesforce authentication
+ // // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
+ // },
+ // body: JSON.stringify(sfDataSync)
+ // });
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
- }
+ // if (!response.ok) {
+ // const errorText = await response.text();
+ // throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
+ // }
- const result = await response.json();
- // console.log("✅ Successfully synced VA data to Salesforce:", result);
+ // const result = await response.json();
+ // // console.log("✅ Successfully synced VA data to Salesforce:", result);
- return {
- closedWorkTracks: openWorkTracks.length,
- logoutLogId: logoutLog._id,
- SyncSalesforce: result
- };
- } catch (error) {
- console.error("Failed to sync with Salesforce:", error.message);
- console.log(error)
-
- // Create a custom error to distinguish Salesforce errors
- const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
- salesforceError.isSalesforceError = true;
- throw salesforceError;
- }
-
- // return {
+ // return {
// closedWorkTracks: openWorkTracks.length,
// logoutLogId: logoutLog._id,
+ // SyncSalesforce: result
// };
+ // } catch (error) {
+ // console.error("Failed to sync with Salesforce:", error.message);
+ // console.log(error)
+
+ // // Create a custom error to distinguish Salesforce errors
+ // const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
+ // salesforceError.isSalesforceError = true;
+ // throw salesforceError;
+ // }
+
+ return {
+ closedWorkTracks: openWorkTracks.length,
+ logoutLogId: logoutLog._id,
+ };
} catch (error) {
console.error("Error closing previous session:", error);
diff --git a/src/controllers/va/vaLeaves/vaLeavesApply.controller.js b/src/controllers/va/vaLeaves/vaLeavesApply.controller.js
index 35ba56d..45a6451 100644
--- a/src/controllers/va/vaLeaves/vaLeavesApply.controller.js
+++ b/src/controllers/va/vaLeaves/vaLeavesApply.controller.js
@@ -5,7 +5,7 @@ import { VAModel, LeavesModel } from "../../../models/vaModel.js";
async function va_leave_apply_function(req, res) {
try {
const accessToken = req.headers.token;
-
+
if (!accessToken) {
return res.status(401).json({ success: false, message: "Missing token" });
}
@@ -25,9 +25,9 @@ async function va_leave_apply_function(req, res) {
const { dateFrom, dateTo, leaveType, comment } = req.body;
if (!dateFrom || !dateTo || !leaveType || !comment) {
- return res.status(400).json({
- success: false,
- message: "dateFrom, dateTo, leaveType, and comment are required"
+ return res.status(400).json({
+ success: false,
+ message: "dateFrom, dateTo, leaveType, and comment are required"
});
}
@@ -36,49 +36,49 @@ async function va_leave_apply_function(req, res) {
try {
startDate = new Date(dateFrom);
endDate = new Date(dateTo);
-
+
// Set to start of day for accurate calculation
startDate.setHours(0, 0, 0, 0);
endDate.setHours(0, 0, 0, 0);
-
+
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
- return res.status(400).json({
- success: false,
- message: "Invalid date format. Use valid date format (YYYY-MM-DD)"
+ return res.status(400).json({
+ success: false,
+ message: "Invalid date format. Use valid date format (YYYY-MM-DD)"
});
}
} catch (error) {
- return res.status(400).json({
- success: false,
- message: "Invalid date format"
+ return res.status(400).json({
+ success: false,
+ message: "Invalid date format"
});
}
// Ensure start date is before or equal to end date
if (startDate > endDate) {
- return res.status(400).json({
- success: false,
- message: "Start date must be before or equal to end date"
+ return res.status(400).json({
+ success: false,
+ message: "Start date must be before or equal to end date"
});
}
// Ensure dates are not in the past
const today = new Date();
today.setHours(0, 0, 0, 0);
-
+
if (startDate < today) {
- return res.status(400).json({
- success: false,
- message: "Cannot apply for leave for past dates"
+ return res.status(400).json({
+ success: false,
+ message: "Cannot apply for leave for past dates"
});
}
// Validate leave type
const validLeaveTypes = ["SICK", "PTO"];
if (!validLeaveTypes.includes(leaveType)) {
- return res.status(400).json({
- success: false,
- message: "Invalid leave type. Must be SICK or PTO"
+ return res.status(400).json({
+ success: false,
+ message: "Invalid leave type. Must be SICK or PTO"
});
}
@@ -87,9 +87,9 @@ async function va_leave_apply_function(req, res) {
const totalDays = Math.floor(timeDifference / (1000 * 60 * 60 * 24)) + 1; // +1 to include both start and end dates
if (totalDays <= 0) {
- return res.status(400).json({
- success: false,
- message: "Invalid date range"
+ return res.status(400).json({
+ success: false,
+ message: "Invalid date range"
});
}
@@ -106,9 +106,9 @@ async function va_leave_apply_function(req, res) {
});
if (existingLeave) {
- return res.status(400).json({
- success: false,
- message: "You already have a pending leave request that overlaps with these dates"
+ return res.status(400).json({
+ success: false,
+ message: "You already have a pending leave request that overlaps with these dates"
});
}
@@ -125,9 +125,9 @@ async function va_leave_apply_function(req, res) {
});
if (existingApprovedLeave) {
- return res.status(400).json({
- success: false,
- message: "You already have an approved leave that overlaps with these dates"
+ return res.status(400).json({
+ success: false,
+ message: "You already have an approved leave that overlaps with these dates"
});
}
@@ -147,94 +147,95 @@ async function va_leave_apply_function(req, res) {
await leaveRequest.save();
try {
- // const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/timesheet-adjustment`;
- // const sfData = {
- // "id": leaveRequest._id,
- // "va": {
- // "id": vaId
- // },
- // "dateFrom": startDate.toISOString().split('T')[0],
- // "dateTo": endDate.toISOString().split('T')[0],
- // "totalDays": totalDays,
- // "leaveType": leaveType,
- // "comment": processedComment.plainText, // Send plain text to Salesforce
- // "attachments": processedComment.extractedImages, // Send extracted images info
- // "status": "PENDING",
- // "rejectionReason": ""
- // }
+ const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/leave-request`;
+ const sfData = {
+ "leaveId": leaveRequest._id.toString(),
+ // "va": {
+ // "id": vaId
+ // },
+ "va": vaId.toString(),
+ "dateFrom": startDate.toISOString().split('T')[0],
+ "dateTo": endDate.toISOString().split('T')[0],
+ "totalDays": totalDays,
+ "leaveType": leaveType,
+ "comment": processedComment.plainText, // Send plain text to Salesforce
+ "attachments": processedComment.extractedImages, // Send extracted images info
+ "status": "PENDING",
+ "rejectionReason": ""
+ }
- // const response = await fetch(salesforceUrl, {
- // method: 'POST',
- // headers: {
- // 'Content-Type': 'application/json',
- // // Add any required headers for Salesforce authentication
- // // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
- // },
- // body: JSON.stringify({"data": sfData})
- // });
-
- // if (!response.ok) {
- // const errorText = await response.text();
- // throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
- // }
+ const response = await fetch(salesforceUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ // Add any required headers for Salesforce authentication
+ // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
+ },
+ body: JSON.stringify({ "data": sfData })
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
+ }
// const result = await response.json();
- // // console.log("✅ Successfully synced VA data to Salesforce:", result);
-
- return res.status(201).json({
- success: true,
- message: "Leave request submitted successfully",
- data: {
- leaveId: leaveRequest._id,
- dateFrom: startDate.toISOString().split('T')[0],
- dateTo: endDate.toISOString().split('T')[0],
- totalDays: totalDays,
- leaveType,
- comment: processedComment.processedHTML,
- // attachments: processedComment.extractedImages,
- status: "PENDING"
- },
- // SyncSalesforce: result
- });
+ // console.log("✅ Successfully synced VA data to Salesforce:", result);
+
+ // return res.status(201).json({
+ // success: true,
+ // message: "Leave request submitted successfully",
+ // data: {
+ // leaveId: leaveRequest._id,
+ // dateFrom: startDate.toISOString().split('T')[0],
+ // dateTo: endDate.toISOString().split('T')[0],
+ // totalDays: totalDays,
+ // leaveType,
+ // comment: processedComment.processedHTML,
+ // // attachments: processedComment.extractedImages,
+ // status: "PENDING"
+ // },
+ // // SyncSalesforce: result
+ // });
} catch (error) {
console.error("Failed to sync with Salesforce:", error.message);
console.log(error)
-
+
// Create a custom error to distinguish Salesforce errors
const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
salesforceError.isSalesforceError = true;
throw salesforceError;
}
- // return res.status(201).json({
- // success: true,
- // message: "Leave request submitted successfully",
- // data: {
- // leaveId: leaveRequest._id,
- // dateFrom: startDate.toISOString().split('T')[0],
- // dateTo: endDate.toISOString().split('T')[0],
- // totalDays: totalDays,
- // leaveType,
- // comment,
- // status: "PENDING"
- // }
- // });
+ return res.status(201).json({
+ success: true,
+ message: "Leave request submitted successfully",
+ data: {
+ leaveId: leaveRequest._id,
+ dateFrom: startDate.toISOString().split('T')[0],
+ dateTo: endDate.toISOString().split('T')[0],
+ totalDays: totalDays,
+ leaveType,
+ comment,
+ status: "PENDING"
+ }
+ });
} catch (error) {
console.error("Leave apply error:", error);
-
+
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ success: false, message: "Invalid token" });
}
-
+
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ success: false, message: "Token expired" });
}
-
- return res.status(500).json({
- success: false,
+
+ return res.status(500).json({
+ success: false,
message: "Internal Server Error",
- error: error.message
+ error: error.message
});
}
}
@@ -277,7 +278,7 @@ function extractPlainTextFromHTML(html) {
.replace(/<[^>]*>/g, ' ') // Replace tags with spaces
.replace(/\s+/g, ' ') // Collapse multiple spaces
.trim();
-
+
// Decode common HTML entities
plainText = plainText
.replace(/ /g, ' ')
@@ -304,7 +305,7 @@ function extractAndProcessBase64Images(html) {
try {
// Regex to find base64 images in HTML (various formats)
const base64Regex = /
]+src="data:image\/([a-zA-Z0-9+]+);base64,([^"]+)"[^>]*>/gi;
-
+
let match;
let imageIndex = 0;
@@ -316,7 +317,7 @@ function extractAndProcessBase64Images(html) {
// Create a unique filename
const timestamp = Date.now();
const filename = `leave_image_${timestamp}_${imageIndex}.${imageType}`;
-
+
// Calculate approximate file size
const size = Math.floor((base64Data.length * 3) / 4); // Base64 size calculation
@@ -332,7 +333,7 @@ function extractAndProcessBase64Images(html) {
// Replace base64 with a reference in HTML
const replacementHTML = `
`;
-
+
processedHTML = processedHTML.replace(fullMatch, replacementHTML);
imageIndex++;
diff --git a/src/controllers/va/vaTimesheetAdjustment/vaManageTimesheetAdjustment.controller.js b/src/controllers/va/vaTimesheetAdjustment/vaManageTimesheetAdjustment.controller.js
index f114406..bc4e6a7 100644
--- a/src/controllers/va/vaTimesheetAdjustment/vaManageTimesheetAdjustment.controller.js
+++ b/src/controllers/va/vaTimesheetAdjustment/vaManageTimesheetAdjustment.controller.js
@@ -31,7 +31,7 @@ async function va_timesheet_manage_adjustment_request(req, res) {
});
}
- if (action === "approve") {
+ if (action === "APPROVE") {
// Create new log entries in LoggedModel
const loginLog = new LoggedModel({
loggedType: "LOGIN",
@@ -78,7 +78,7 @@ async function va_timesheet_manage_adjustment_request(req, res) {
}
});
- } else if (action === "reject") {
+ } else if (action === "REJECT") {
// Update adjustment record as rejected
adjustment.status = "REJECTED";
adjustment.reviewedAt = new Date();
diff --git a/src/controllers/va/vaTimesheetAdjustment/vaTimesheetAdjustment.controller.js b/src/controllers/va/vaTimesheetAdjustment/vaTimesheetAdjustment.controller.js
index a252386..5ca4a5a 100644
--- a/src/controllers/va/vaTimesheetAdjustment/vaTimesheetAdjustment.controller.js
+++ b/src/controllers/va/vaTimesheetAdjustment/vaTimesheetAdjustment.controller.js
@@ -138,12 +138,12 @@ async function va_timesheet_adjustment_function(req, res) {
try {
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/timesheet-adjustment`;
const sfData = {
- "id": adjustmentRequest._id,
+ "id": adjustmentRequest._id.toString(),
"va": {
- "id": vaId
+ "id": vaId.toString()
},
"project": {
- "id": projectId
+ "id": projectId.toString()
},
"date": adjustmentDate.toISOString().split('T')[0],
"loginTime": loginDateTime.toISOString(),
diff --git a/src/controllers/va/vaTips/vaTipsManage.controller.js b/src/controllers/va/vaTips/vaTipsManage.controller.js
new file mode 100644
index 0000000..bcaf10b
--- /dev/null
+++ b/src/controllers/va/vaTips/vaTipsManage.controller.js
@@ -0,0 +1,143 @@
+import { VAModel, TipsModel, ClientModel } from "../../../models/vaModel.js";
+
+// Approve or reject leave
+async function va_tips_manage_function(req, res) {
+
+ const { clientId, amount, description, vaId } = req.body;
+
+ try {
+ const vaUser = await VAModel.findById(vaId);
+ if (!vaUser) {
+ return res.status(404).json({ success: false, message: "Virtual Assistant not found" });
+ }
+ const clUser = await ClientModel.findById(clientId);
+ if (!clUser) {
+ return res.status(404).json({ success: false, message: "Client not found" });
+ }
+
+ // Create new tip entry
+ const newTip = {
+ clientId,
+ status: "Unbilled",
+ amount: parseFloat(amount).toFixed(2),
+ description: description || "",
+ vaName: vaUser.userDetails.firstName ? vaUser.userDetails.firstName : "" + " " + vaUser.userDetails.lastName ? vaUser.userDetails.lastName : "",
+ vaId,
+ };
+
+ const createdTip = await TipsModel.create(newTip);
+
+ const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-tips`;
+
+ const sfData = {
+ "id": createdTip._id.toString(),
+ "vaId": createdTip.vaId.toString(),
+ "status": "Unbilled",
+ "clientId": createdTip.clientId.toString(),
+ "comment": createdTip.description || "",
+ "tipAmount": Number(parseFloat(createdTip.amount).toFixed(2)), // Convert to cents
+ }
+
+ const response = await fetch(salesforceUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ // Add any required headers for Salesforce authentication
+ // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
+ },
+ body: JSON.stringify({ ...sfData })
+ });
+
+ if (response.ok) {
+ console.log("✅ Successfully synced Tip data to Salesforce");
+ return res.status(201).json({ success: true, data: createdTip });
+ } else {
+ TipsModel.findByIdAndDelete(createdTip._id);
+ const errorText = await response.text();
+ throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
+ }
+
+ } catch (error) {
+ console.log(error);
+ res.status(500).json({ success: false, message: "Internal server error" });
+ }
+}
+
+async function va_tips_get(req, res) {
+
+ const { clientId } = req.body;
+
+ try {
+ const clUser = await ClientModel.findById(clientId);
+ if (!clUser) {
+ return res.status(404).json({ success: false, message: "Client not found" });
+ }
+ const data = await TipsModel.find({ clientId: clientId, status: "Unbilled" });
+ return res.status(200).json({ success: true, data: data });
+ } catch (error) {
+ console.log(error);
+ res.status(500).json({ success: false, message: "Internal server error" });
+ }
+}
+
+async function va_tips_update(req, res) {
+ const { tipId } = req.params;
+ const { update } = req.body;
+
+ try {
+ const tipData = await TipsModel.findById(tipId);
+ if (!tipData) {
+ return res.status(404).json({ success: false, message: "Tip not found" });
+ }
+
+ await TipsModel.updateOne({ _id: tipId }, update);
+ return res.status(200).json({ success: true, message: "Tip updated successfully" });
+
+ } catch (error) {
+ console.log(error);
+ res.status(500).json({ success: false, message: "Internal server error" });
+ }
+}
+
+async function va_tips_delete(req, res) {
+ const { tipId } = req.params;
+
+ try {
+
+
+ const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-tips`;
+
+ // const sfData = {
+ // "id": createdTip._id.toString(),
+ // "vaId": createdTip.vaId.toString(),
+ // "status": "Unbilled",
+ // "clientId": createdTip.clientId.toString(),
+ // "comment": createdTip.description,
+ // "tipAmount": Number(parseFloat(createdTip.amount).toFixed(2)), // Convert to cents
+ // }
+
+ const response = await fetch(salesforceUrl, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ // Add any required headers for Salesforce authentication
+ // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
+ },
+ body: JSON.stringify({ "id": tipId})
+ });
+
+ if (response.ok) {
+ await TipsModel.findOneAndDelete({ _id: tipId });
+ return res.status(200).json({ success: true, message: "Tip updated successfully" });
+ } else {
+ const errorText = await response.text();
+ throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
+ }
+ } catch (error) {
+ console.log(error);
+ res.status(500).json({ success: false, message: "Internal server error" });
+ }
+}
+
+
+export { va_tips_manage_function, va_tips_get, va_tips_update, va_tips_delete };
\ No newline at end of file
diff --git a/src/models/vaModel.js b/src/models/vaModel.js
index e6794e1..3b797f3 100644
--- a/src/models/vaModel.js
+++ b/src/models/vaModel.js
@@ -89,9 +89,16 @@ const projectSchema = new Schema({
},
vaAssigned: [
{
- type: Types.ObjectId,
- ref: "VA",
- },
+ _id: false,
+ vaId: {
+ type: Types.ObjectId,
+ ref: "VA",
+ },
+ isFlexibleAllowed: {
+ type: Boolean,
+ default: true
+ }
+ }
],
dedicatedHours: {
type: String,
@@ -110,7 +117,7 @@ const projectSchema = new Schema({
type: String,
default: null
},
- isFlexibleAllowedPt: {
+ isFlexibleAllowed: {
type: Boolean,
default: true
},
@@ -189,7 +196,7 @@ const clientDetailsSchema = new Schema({
enum: ["CHECKING", "SAVINGS"],
type: String,
},
-
+
accountHolderName: {
type: String,
},
@@ -363,36 +370,36 @@ const vaWorkTrackSchema = new Schema({
)
const passwordResetSchema = new mongoose.Schema({
- email: {
- type: String,
- required: true,
- lowercase: true,
- index: true
- },
- token: {
- type: String,
- required: true,
- unique: true,
- index: true
- },
- userType: {
- type: String,
- required: true,
- enum: ['va', 'client'],
- index: true
- },
- expiresAt: {
- type: Date,
- required: true,
- index: true,
- expires: 3600 // TTL index for 1 hour
- },
- used: {
- type: Boolean,
- default: false
- }
+ email: {
+ type: String,
+ required: true,
+ lowercase: true,
+ index: true
+ },
+ token: {
+ type: String,
+ required: true,
+ unique: true,
+ index: true
+ },
+ userType: {
+ type: String,
+ required: true,
+ enum: ['va', 'client'],
+ index: true
+ },
+ expiresAt: {
+ type: Date,
+ required: true,
+ index: true,
+ expires: 3600 // TTL index for 1 hour
+ },
+ used: {
+ type: Boolean,
+ default: false
+ }
}, {
- timestamps: true
+ timestamps: true
});
passwordResetSchema.index({ email: 1, used: 1 });
@@ -511,6 +518,37 @@ const leavesSchema = new Schema({
}
}, { timestamps: true });
+
+/* ---------------------- Tips Schema ---------------------- */
+const tipsSchema = new Schema({
+ clientId: {
+ type: Types.ObjectId,
+ ref: "Client"
+ },
+ vaId: {
+ type: Types.ObjectId,
+ ref: "VA"
+ },
+ vaName: {
+ type: String,
+ required: true
+ },
+ status: {
+ type: String,
+ enum: ["Unbilled", "Billed"],
+ default: "Unbilled",
+ required: true
+ },
+ amount: {
+ type: String,
+ required: true
+ },
+ description: {
+ type: String,
+ }
+}, { timestamps: true })
+
+
/* ---------------------- Model Exports ---------------------- */
const VAModel = model("VA", vaSchema);
const ClientModel = model("Client", clientSchema);
@@ -520,5 +558,6 @@ const WorkTrackModel = model("TrackWork", vaWorkTrackSchema);
const PasswordResetModel = model("PasswordReset", passwordResetSchema);
const AdjustedHoursModel = model("AdjustedHours", adjustedHoursSchema);
const LeavesModel = model("Leaves", leavesSchema);
+const TipsModel = model("Tips", tipsSchema);
-export { VAModel, ClientModel, ProjectModel, LoggedModel, WorkTrackModel, PasswordResetModel, AdjustedHoursModel, LeavesModel };
\ No newline at end of file
+export { VAModel, ClientModel, ProjectModel, LoggedModel, WorkTrackModel, PasswordResetModel, AdjustedHoursModel, LeavesModel, TipsModel };
\ No newline at end of file
diff --git a/src/routes/v1/client.routes.js b/src/routes/v1/client.routes.js
index 9e2c6e6..97f95e1 100644
--- a/src/routes/v1/client.routes.js
+++ b/src/routes/v1/client.routes.js
@@ -13,6 +13,7 @@ import { client_dashboard_va_log } from "../../controllers/client/dashboard/clie
import { client_verify_email } from "../../controllers/client/vaClientEmailVerify.controller.js";
import { client_dashboard_projects } from "../../controllers/client/dashboard/clientDashboardProjects.controller.js";
import { client_logout_callback } from "../../controllers/client/dashboard/clientLogout.controller.js";
+import { va_tips_delete, va_tips_get, va_tips_manage_function, va_tips_update } from "../../controllers/va/vaTips/vaTipsManage.controller.js";
// clients routes
clientRoutes.post("/register", va_register_client); // Register a new VA
@@ -22,11 +23,14 @@ clientRoutes.post("/forgot-password", client_request_password_reset);
clientRoutes.post("/verify-client-email", client_verify_email);
clientRoutes.post("/logout", clientAuthValidator, client_logout_callback);
+clientRoutes.post("/tips", clientAuthValidator, va_tips_manage_function);
+clientRoutes.get("/tips", clientAuthValidator, va_tips_get);
+clientRoutes.patch("/tips/:tipId", clientAuthValidator, va_tips_update);
+clientRoutes.delete("/tips/:tipId", clientAuthValidator, va_tips_delete);
+
// clientRoutes.get("/:clientID?", va_clients_callback); // VA Clients Callback for getting all VA's or specific client by ID (NC)
clientRoutes.get("/:clientId/projects/:projectId", va_clients_projects_callback); // VA Clients Projects Callback (NC)
-
-
// dashboard API's
clientRoutes.get("/dashboard", clientAuthValidator, client_dashboard); // VA Clients Projects Callback
clientRoutes.post("/dashboard/va-report", clientAuthValidator, client_dashboard_va_log); // VA details
diff --git a/src/server.js b/src/server.js
index 93c25a3..370bbc7 100644
--- a/src/server.js
+++ b/src/server.js
@@ -18,6 +18,7 @@ import apiRoutes from './routes/index.js'
import { render_reset_form } from "./controllers/passwordReset.controllers.js";
+import expressLayouts from "express-ejs-layouts";
import jwt from "jsonwebtoken";
const app = express();
@@ -39,6 +40,11 @@ app.on('certificate-error', (event, webContents, url, error, certificate, callba
});
app.set("view engine", "ejs");
+
+app.use(expressLayouts);
+
+// DEFAULT layout for all pages
+app.set("layout", "layouts/dashboard");
const PORT = process.env.PORT || 8000;
app.use(cors({
@@ -118,9 +124,16 @@ app.get('/health', (req, res) => {
res.status(201).send({ status: "Server is healthy" });
})
+
+
+
app.get("/client/login", (req, res) => {
- res.render("client_login")
-})
+ res.render("client/client_login", {
+ layout: "layouts/auth", // ⬅️ override layout
+ title: "DocVA | Login"
+ });
+});
+
app.get("/client/forgot-password", (req, res) => {
res.render("forgot_password")
})
@@ -129,8 +142,11 @@ app.get("/client/forgot-password", (req, res) => {
app.get('/client/dashboard', async (req, res) => {
try {
// const dashboardData = await dashboardService.getClientDashboard(req.user.id);
- // res.render('client/dashboard-dashboard', dashboardData);
- res.render('client/dashboard-dashboard');
+ res.render('client/dashboard-dashboard', {
+ title: "Client Dashboard",
+ pageScript: "dashboard.js"
+ });
+ // res.render('layouts/dashboard');
} catch (error) {
console.error('Dashboard error:', error);
res.status(500).render('client/dashboard-dashboard', { error: 'Failed to load dashboard' });
@@ -140,7 +156,11 @@ app.get('/client/dashboard', async (req, res) => {
app.get('/client/virtual-assistants', async (req, res) => {
try {
// const vaData = await virtualAssistantService.getClientVAs(req.user.id);
- res.render('client/dashboard-virtual-assistants');
+ res.render('client/dashboard-virtual-assistants', {
+ title: "Virtual Assistants",
+ pageScript:"dashboard-virtual-assistants.js"
+
+ });
// res.render('client/dashboard-virtual-assistants', vaData);
} catch (error) {
console.error('Virtual assistants error:', error);
@@ -151,7 +171,11 @@ app.get('/client/virtual-assistants', async (req, res) => {
app.get('/client/projects', async (req, res) => {
try {
// const vaData = await virtualAssistantService.getClientVAs(req.user.id);
- res.render('client/dashboard-projects');
+ res.render('client/dashboard-projects', {
+ title: "Projects",
+ pageScript:"projects.js"
+
+ });
} catch (error) {
console.error('Projects error:', error);
res.status(500).render('client/dashboard-projects', { error: 'Failed to load projects' });
@@ -161,23 +185,25 @@ app.get('/client/projects', async (req, res) => {
app.get('/client/profile', async (req, res) => {
try {
// const vaData = await virtualAssistantService.getClientVAs(req.user.id);
- res.render('client/dashboard-profile');
+ res.render('client/dashboard-profile',{
+ title: "Profile",
+ pageScript:"profile.js"
+
+ });
} catch (error) {
console.error('Projects error:', error);
res.status(500).render('client/dashboard-profile', { error: 'Failed to load profile' });
}
});
-app.get('/client/virtual-assistants/manage-tips', async (req, res) => {
- try {
- // const vaData = await virtualAssistantService.getClientVAs(req.user.id);
- res.render('client/dashboard-manage-va-tips');
- } catch (error) {
- console.error('Projects error:', error);
- res.status(500).render('client/dashboard-manage-va-tips', { error: 'Failed to load profile' });
- }
+app.get('/client/virtual-assistants/manage-tips', (req, res) => {
+ res.render('client/dashboard-manage-va-tips', {
+ title: 'Manage VA Tips',
+ pageScript: 'manage-tips.js'
+ });
});
+
app.get("/live/:projectId/:vaId", async (req, res) => {
const { projectId, vaId } = req.params;
@@ -400,6 +426,36 @@ io.on("connection", (socket) => {
socket.to(vaSocketRespectToClient.get(`${clientId}-${vaId}-${projectId}-va`)).emit("appToCapture", { appTitle });
});
+ socket.on("requestVisibleApps", async ({ token }) => {
+ try {
+ // Verify token
+ const decoded = jwt.verify(token, process.env.JWT_SECRET_ACCESS_TOKEN);
+ const vaEmail = decoded.email;
+
+ // Find VA in DB
+ const vaUser = await VAModel.findOne({ "userDetails.email": vaEmail });
+ if (!vaUser) {
+ console.log("❌ Unauthorized VA");
+ return;
+ }
+
+ const vaId = vaUser._id.toString();
+ console.log("🟢 Received Apps from VA:", vaId);
+
+ // Broadcast image only to allowed clients
+ for (let [_, clientSocket] of io.sockets.sockets) {
+ if (
+ clientSocket.data.role === "client" &&
+ clientSocket.data.allowedVAs?.includes(vaId)
+ ) {
+ clientSocket.emit("captureAppsTracker");
+ }
+ }
+ } catch (error) {
+ console.error("❌ Token verification failed:", err.message);
+ }
+ });
+
socket.on("getVisibleApps", async ({ token, apps }) => {
try {
// Verify token
@@ -431,9 +487,27 @@ io.on("connection", (socket) => {
});
- socket.on('disconnect', () => {
- console.log('a user Disconnected');
+ // socket.on('disconnect', () => {
+ // console.log('a user Disconnected');
+ // });
+
+ // When client leaves or closes tab
+ socket.on("disconnect", () => {
+ if (socket.data.role === "client") {
+ const { clientId, vaId, projectId } = socket.data;
+
+ const vaSocketId = vaSocketRespectToClient.get(
+ `${clientId}-${vaId}-${projectId}-va`
+ );
+
+ if (vaSocketId) {
+ io.to(vaSocketId).emit("stopSendAppsDetails");
+ }
+ }
+
+ console.log("❌ Client disconnected");
});
+
});
app.use((req, res) => {
diff --git a/views/client_login.ejs b/views/client/client_login.ejs
similarity index 100%
rename from views/client_login.ejs
rename to views/client/client_login.ejs
diff --git a/views/client/dashboard-dashboard.ejs b/views/client/dashboard-dashboard.ejs
index e637a04..7e1e9e4 100644
--- a/views/client/dashboard-dashboard.ejs
+++ b/views/client/dashboard-dashboard.ejs
@@ -1,557 +1,72 @@
-
-
-
-
-
- DocVA Client | Dashboard
-
-
-
-
-
-