Compare commits

...

103 Commits

Author SHA1 Message Date
=bhagwanSingh 4e782e980c a 2025-12-22 15:07:01 +05:30
=bhagwanSingh bfcd64cf0e Generate URL Update 2025-12-22 15:02:11 +05:30
Aakash Mittal bc3b81e12f Added credits tab in client dashboard 2025-12-22 13:56:02 +05:30
Aakash Mittal 41f5eff4d7 Fixed UI issues 2025-12-22 12:12:00 +05:30
Aakash Mittal 4d1abaf9aa merge master brance 2025-12-22 11:33:56 +05:30
=bhagwanSingh 0c14e26937 Added Mac Build Download URL 2025-12-19 18:59:26 +05:30
Aakash Mittal 68a248c25a merge ejs updated layout, fixed api 2025-12-19 18:10:39 +05:30
=bhagwanSingh 221e867a21 Merge branch 'backend' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware 2025-12-18 19:53:34 +05:30
=bhagwanSingh 39f415b685 a 2025-12-18 19:51:58 +05:30
Aakash Mittal d4cdc94bbd Added company name in client registration 2025-12-18 17:55:11 +05:30
Aakash Mittal 2d61f25d5b merge branch 2025-12-18 17:16:45 +05:30
=bhagwanSingh 99d60185a9 Merge branch 'backend' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware - Akash merge fName lName 18 Dec 2025-12-18 16:53:20 +05:30
=bhagwanSingh da3deac57b A 2025-12-18 16:45:08 +05:30
Aakash Mittal 0b0f1d5ab3 merge3 2025-12-18 16:24:26 +05:30
Aakash Mittal b120f75c40 18-11 2025-12-18 16:23:03 +05:30
JaiPandey425 604afe9b72 12-08/2025 - va regiseter client register forget password layout fixed 2025-12-18 16:20:02 +05:30
Aakash Mittal 9f8c76cabd A 2025-12-18 15:49:04 +05:30
Aakash Mittal 5822e67296 Updated client register for first and last name 2025-12-18 15:46:22 +05:30
=bhagwanSingh 412511afec va.routes added authValidator 2025-12-18 14:02:55 +05:30
=bhagwanSingh b3bb9697e8 AuthFetch import add 2025-12-18 14:01:18 +05:30
Aakash Mittal 90438ea937 Fixed time badge calculation in va logs on client dashboard 2025-12-18 11:55:19 +05:30
=bhagwanSingh fd9bf9e517 Merge branch 'backend' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware 2025-12-17 20:47:15 +05:30
Aakash Mittal 33779a6eb8 Added logs in client dashboard 2025-12-17 20:01:21 +05:30
=bhagwanSingh 44132c351e A 2025-12-17 18:32:19 +05:30
=bhagwanSingh 79baf21819 Merge branch 'backend' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware - merge backend f 2025-12-17 18:26:06 +05:30
Aakash Mittal 6cd02b0f85 6:15 2025-12-17 18:15:17 +05:30
Aakash Mittal 344d6181fa revert commit 2025-12-17 16:57:58 +05:30
Aakash Mittal d036a71810 A 2025-12-17 16:53:43 +05:30
=bhagwanSingh a5f8e7bc7f Update getDayNumber 2025-12-17 16:25:54 +05:30
Aakash Mittal 60be1533bc ejs branch merge - 4:14 - 17 Dec 2025-12-17 16:15:53 +05:30
=bhagwanSingh dad88d02bb Merge branch 'backend' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware - 17 Dev - 3:48 2025-12-17 15:49:01 +05:30
Aakash Mittal 46f2d7377f Added days wise dedicated hours in projects 2025-12-17 15:45:59 +05:30
=bhagwanSingh b9f2eb10b4 Auth Fetch Update in Client Register 2025-12-17 14:20:15 +05:30
JaiPandey425 5d952b04cf fixed the layout and file based ejs routing 2025-12-17 13:18:53 +05:30
Aakash Mittal 5f043ed80a Added password option in client registration 2025-12-17 12:27:19 +05:30
=bhagwanSingh efa6ce929e A 2025-12-17 09:29:28 +05:30
=bhagwanSingh b64962738a a 2025-12-17 09:26:04 +05:30
=bhagwanSingh 3d337780da A 2025-12-17 09:24:11 +05:30
=bhagwanSingh 2e5fed545f Auth Fetch and Server Health Update 2025-12-17 09:13:45 +05:30
=bhagwanSingh dba68ed584 Password Rest update 2025-12-16 19:25:41 +05:30
=bhagwanSingh 320d44f4b5 Added authFetch currently getting access toke for all request as health check api is not available on sf ye 2025-12-16 19:01:13 +05:30
=bhagwanSingh 166cf4db45 Merge branch 'backend' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware 2025-12-16 15:41:57 +05:30
=bhagwanSingh 085e0c0e6d VPN Update 2025-12-16 14:45:30 +05:30
=bhagwanSingh 15bbbd8a98 a 2025-12-16 14:26:17 +05:30
=bhagwanSingh c20db85ada a 2025-12-16 14:21:00 +05:30
=bhagwanSingh 0e8ce1f850 a 2025-12-16 13:42:50 +05:30
=bhagwanSingh 9e4d118009 a 2025-12-16 12:47:57 +05:30
=bhagwanSingh 668c81b86e a 2025-12-16 12:33:20 +05:30
=bhagwanSingh b4b0e27ecf a 2025-12-16 12:21:10 +05:30
=bhagwanSingh c74f792df6 localhost not binding with port 2025-12-16 12:09:46 +05:30
=bhagwanSingh f538371116 Server Keys env update 2025-12-16 11:47:27 +05:30
=bhagwanSingh fd382ccb93 a 2025-12-16 07:29:45 +05:30
Aakash Mittal 287d078b49 Updated track work API 2025-12-15 20:00:02 +05:30
=bhagwanSingh e79383c1cf Update 2025-12-15 18:58:56 +05:30
=bhagwanSingh 67af7df83c Added VPN Disconnect 2025-12-15 16:26:26 +05:30
=bhagwanSingh cbfa03e57c wg0 permission issue 2025-12-15 13:44:59 +05:30
Aakash Mittal 645adea832 Updated track logs api 2025-12-15 13:40:36 +05:30
=bhagwanSingh 03814c6c99 VPN Setup 2025-12-15 12:40:27 +05:30
Aakash Mittal 4ed62b25e8 !5 dec 2025-12-15 11:50:07 +05:30
=bhagwanSingh 2e7731e704 Adding uv Ip on Listener 2025-12-12 14:21:55 +05:30
=bhagwanSingh 9a811e6f00 Password Reset on register 2025-12-12 12:30:40 +05:30
=bhagwanSingh fc821c70ca Test 2025-12-10 20:26:32 +05:30
=bhagwanSingh 6fd3da0837 Test 2025-12-10 20:24:38 +05:30
=bhagwanSingh 140b669bdb Project not showing Fixed 2025-12-10 14:35:53 +05:30
=bhagwanSingh 0ef8d60060 Bug Resolve 2025-12-09 19:15:26 +05:30
=bhagwanSingh bc98edcb4b a 09-Dec 2025-12-09 07:21:19 +05:30
=bhagwanSingh 00cb793a7c Va and project List API update 2025-12-05 13:29:04 +05:30
=bhagwanSingh 59561f211d VaSlip Update 2025-12-04 13:35:12 +05:30
=bhagwanSingh 4d617d3113 Client and VA Update and VA Tip 2025-12-04 11:46:42 +05:30
=bhagwanSingh 15f0f3d782 Socke, Time and leave adjustment update 2025-12-02 18:45:28 +05:30
JaiPandey425 e09e0aea1d profile data fetch error 2025-12-02 16:52:17 +05:30
=bhagwanSingh 7ac50fe720 Manage Timesheet update 2025-12-02 13:20:04 +05:30
=bhagwanSingh b085a88579 ENUM update 2025-12-02 13:01:09 +05:30
=bhagwanSingh 5ee97a7d44 Test auto deploy - Done 2025-12-02 12:32:58 +05:30
=bhagwanSingh c04264f054 Test auto deploy and VPN 2025-12-02 12:32:00 +05:30
=bhagwanSingh a7d770498c Leave API UCment 2025-12-01 17:25:16 +05:30
=bhagwanSingh 2ca5146846 Merge branch 'backend' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware - 01-12 2025-12-01 15:49:50 +05:30
=bhagwanSingh 6c8e85336c Test Deploy - Done 2025-12-01 14:04:23 +05:30
=bhagwanSingh 0c578d8121 Test Deploy 2025-12-01 14:02:17 +05:30
=bhagwanSingh d0bb922da1 Server Update 2025-12-01 13:52:55 +05:30
Aakash Mittal 3a6e86d6da Update 01-12 2025-12-01 12:52:06 +05:30
=bhagwanSingh 1baa6da4c7 Merge branch 'backend' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware - 30-11 2025-11-30 11:32:37 +05:30
Aakash Mittal 68e3cf14c9 Added dark mode support in dashboard, updated payroll API 2025-11-28 16:30:48 +05:30
=bhagwanSingh 3154a7e0d3 Merge branch 'backend' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware | 28-11 | 16:21 2025-11-28 16:21:40 +05:30
=bhagwanSingh 142f4f77cd 28-11 2025-11-28 16:19:19 +05:30
Aakash Mittal 84871c7236 Updated api 2025-11-28 15:27:48 +05:30
Aakash Mittal 9de7384020 Added payslip API 2025-11-28 13:15:25 +05:30
Aakash Mittal 870d81e2ca Updated dashboard, Added payroll API 2025-11-28 12:51:08 +05:30
Aakash Mittal 4d757daf97 Updated client dashboard 2025-11-27 19:58:33 +05:30
Aakash Mittal 16a62670e7 Added delete route form project, va, and client - Updated leaves and time adjustment api 2025-11-26 19:38:08 +05:30
Aakash Mittal 39010ee61c Added delete VA and project route, fixed tracker api issues 2025-11-25 19:39:48 +05:30
Aakash Mittal 9c736dea02 24-11-2025 2025-11-24 19:49:28 +05:30
=bhagwanSingh c7f44c7754 Revert "Client register error - fix"
This reverts commit 384f75f5ef.
2025-11-24 18:56:44 +05:30
Aakash Mittal e9cba202f1 Fixed js error in client register 2025-11-24 18:24:48 +05:30
Aakash Mittal 227d591e68 Merge branch 'master' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware into backend 2025-11-24 18:10:06 +05:30
Aakash Mittal 8bff39fb4c Fixed va register bugs 2025-11-24 18:09:49 +05:30
=bhagwanSingh 384f75f5ef Client register error - fix 2025-11-24 18:09:37 +05:30
Aakash Mittal 68d81f8e1c a 2025-11-24 16:53:28 +05:30
Aakash Mittal 5f50082f87 Added projects in dashboard 2025-11-24 16:47:53 +05:30
=bhagwanSingh 599779ee8e a 2025-11-24 16:41:45 +05:30
=bhagwanSingh dcda1629ff Merge branch 'merge2' 2025-11-24 13:47:32 +05:30
=bhagwanSingh 52e4eb8133 s 2025-11-24 13:43:23 +05:30
Aakash Mittal 0867a1188f Fixed bugs 2025-11-24 13:22:25 +05:30
98 changed files with 10791 additions and 3329 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules node_modules
logs logs
.env .env
public/builds

0
notePad-wg-cmd.txt Normal file
View File

24
package-lock.json generated
View File

@ -20,6 +20,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.19.2", "express": "^4.19.2",
"express-ejs-layouts": "^2.5.1",
"express-minify": "^1.0.0", "express-minify": "^1.0.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"html-minifier": "^4.0.0", "html-minifier": "^4.0.0",
@ -40,7 +41,8 @@
"uglify-js": "^3.19.3", "uglify-js": "^3.19.3",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0" "winston-daily-rotate-file": "^5.0.0",
"wireguard-tools": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.26.4", "@babel/cli": "^7.26.4",
@ -4231,6 +4233,11 @@
"node": ">= 0.10.0" "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": { "node_modules/express-minify": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/express-minify/-/express-minify-1.0.0.tgz", "resolved": "https://registry.npmjs.org/express-minify/-/express-minify-1.0.0.tgz",
@ -6198,6 +6205,12 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
"node_modules/lodash.once": { "node_modules/lodash.once": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
@ -9338,6 +9351,15 @@
"node": ">= 12.0.0" "node": ">= 12.0.0"
} }
}, },
"node_modules/wireguard-tools": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wireguard-tools/-/wireguard-tools-0.1.0.tgz",
"integrity": "sha512-oBnnSB6wXGEAB1ZVPfbfpX8WlHNw7wv45r13ILX73felVnrGpMxMjFcKGAsMeRfBOGzXcW0LgAWe+T53dRcI+g==",
"license": "MIT",
"dependencies": {
"lodash.mergewith": "^4.6.2"
}
},
"node_modules/wordwrap": { "node_modules/wordwrap": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",

View File

@ -6,6 +6,7 @@
"scripts": { "scripts": {
"start": "nodemon --inspect src/server.js", "start": "nodemon --inspect src/server.js",
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"deploy": "curl -X POST http://35.95.209.161:9000/hooks/deploy-va-middleware",
"build": "npx webpack --mode production", "build": "npx webpack --mode production",
"dev": "npx webpack --mode development" "dev": "npx webpack --mode development"
}, },
@ -24,6 +25,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.19.2", "express": "^4.19.2",
"express-ejs-layouts": "^2.5.1",
"express-minify": "^1.0.0", "express-minify": "^1.0.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"html-minifier": "^4.0.0", "html-minifier": "^4.0.0",
@ -44,7 +46,8 @@
"uglify-js": "^3.19.3", "uglify-js": "^3.19.3",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0" "winston-daily-rotate-file": "^5.0.0",
"wireguard-tools": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.26.4", "@babel/cli": "^7.26.4",

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,52 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
} }
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-card: #ffffff;
--bg-sidebar: linear-gradient(to bottom, #f4f4f5, #d0f9f9, #cac5f7);
--text-primary: #333333;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--border-color: #e5e7eb;
--border-dark: #374151;
--accent-color: #000000;
--accent-light: #070065;
--success-color: #10b981;
--error-color: #dc2626;
--warning-bg: #fff3cd;
--warning-border: #ffeaa7;
--warning-text: #856404;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] {
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--bg-card: #1e1e1e;
--bg-sidebar: linear-gradient(to bottom, #2d2d2d, #1a3a3a, #2a264d);
--text-primary: #f5f5f5;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-color: #374151;
--border-dark: #4b5563;
--accent-color: #ffffff;
--accent-light: #6366f1;
--success-color: #10b981;
--error-color: #ef4444;
--warning-bg: #422006;
--warning-border: #854d0e;
--warning-text: #fef3c7;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.2);
}
body { body {
background-color: #fff; background-color: var(--bg-primary);
color: #333; color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
} }
.container { .container {
@ -21,11 +64,12 @@ body {
display: none; display: none;
flex-direction: column; flex-direction: column;
width: 388px; width: 388px;
background: linear-gradient(to bottom, #f4f4f5, #d0f9f9, #cac5f7); background: var(--bg-sidebar);
padding: 24px; padding: 24px;
border-radius: 14px; border-radius: 14px;
margin: 20px; margin: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow);
transition: all 0.3s ease;
} }
.logo { .logo {
@ -37,18 +81,24 @@ body {
width: 100%; width: 100%;
height: auto; height: auto;
max-width: 196px; max-width: 196px;
filter: brightness(var(--logo-brightness, 1));
} }
[data-theme="dark"] .logo-placeholder img {
--logo-brightness: 0.9;
}
.logo-placeholder h2 { .logo-placeholder h2 {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
color: #000; color: var(--text-primary);
text-align: center; text-align: center;
} }
.form-title-sec { .form-title-sec {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #000; color:var(--text-primary);
text-align: center; text-align: center;
margin-bottom: 24px; margin-bottom: 24px;
} }
@ -68,15 +118,25 @@ body {
border: 2px solid transparent; border: 2px solid transparent;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.1);
}
[data-theme="dark"] .step {
background: rgba(255, 255, 255, 0.05);
} }
.step.active { .step.active {
border-color: #000000; border-color: var(--accent-color);
}
[data-theme="dark"] .step.active {
background: rgba(255, 255, 255, 0.1);
} }
.step.completed .step-number { .step.completed .step-number {
background-color: #000000; background-color: var(--accent-color);
border-color: #000000; border-color: var(--accent-color);
color: white; color: white;
} }
@ -87,30 +147,43 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 2px solid #00000070; border: 2px solid var(--accent-color);
font-weight: 600; font-weight: 600;
color: #000000b5; color: var(--accent-color);
transition: all 0.3s ease; transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.8);
}
[data-theme="dark"] .step-number {
background: rgba(255, 255, 255, 0.1);
border-color: var(--accent-color);
color: var(--text-primary);
} }
.step.active .step-number { .step.active .step-number {
border-color: #000; border-color:var(--text-primary);
color: #000; color:var(--text-primary);
opacity: 1;
background: rgba(255, 255, 255, 0.9);
}
[data-theme="dark"] .step.active .step-number {
background: rgba(255, 255, 255, 0.2);
} }
.step-text { .step-text {
font-size: 14px; font-size: 14px;
color: #000000b5; color: var(--text-primary);
} }
.step.active .step-text { .step.active .step-text {
color: #000; color:var(--text-primary);
} }
.step-connector { .step-connector {
height: 24px; height: 24px;
width: 2px; width: 2px;
background-color: #00000070; background-color: var(--accent-color);
margin-left: 16px; margin-left: 16px;
position: absolute; position: absolute;
top: 46px; top: 46px;
@ -118,7 +191,7 @@ body {
} }
.step.active .step-connector { .step.active .step-connector {
background-color: #000; background-color:var(--text-primary);
} }
/* Main Content Styles */ /* Main Content Styles */
@ -128,15 +201,17 @@ body {
flex-direction: column; flex-direction: column;
padding: 20px; padding: 20px;
overflow-y: auto; overflow-y: auto;
background-color: var(--bg-primary);
} }
.mobile-progress { .mobile-progress {
background-color: #f4f4f5; background-color: var(--bg-secondary);
padding: 16px; padding: 16px;
border-radius: 12px; border-radius: 12px;
margin-bottom: 20px; margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow);
overflow-x: auto; overflow-x: auto;
border: 1px solid var(--border-color);
} }
.mobile-progress ol { .mobile-progress ol {
@ -161,11 +236,12 @@ body {
.form-card { .form-card {
width: 100%; width: 100%;
max-width: 450px; max-width: 450px;
background: white; background: var(--bg-card);
border: 1px solid #e5e7eb; border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); box-shadow: var(--shadow-lg);
padding: 24px; padding: 24px;
transition: all 0.3s ease;
} }
.form-title { .form-title {
@ -173,7 +249,7 @@ body {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
margin-bottom: 24px; margin-bottom: 24px;
color: #000; color:var(--text-primary);
} }
.form-step { .form-step {
@ -191,10 +267,17 @@ body {
.input-container { .input-container {
position: relative; position: relative;
background-color: #f9fafb; background-color: var(--bg-secondary);
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
padding: 2px 0; padding: 2px 0;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.input-container:focus-within {
border-color: var(--accent-light);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
} }
.input-label { .input-label {
@ -203,7 +286,7 @@ body {
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
font-size: 14px; font-size: 14px;
color: #6b7280; color: var(--text-secondary);
transition: all 0.2s ease; transition: all 0.2s ease;
pointer-events: none; pointer-events: none;
} }
@ -216,6 +299,16 @@ body {
outline: none; outline: none;
font-size: 14px; font-size: 14px;
border-radius: 6px; border-radius: 6px;
color: var(--text-primary);
transition: all 0.3s ease;
}
[data-theme="dark"] .input-field, [data-theme="dark"] .select-field {
background-color: #ffffff1a;
}
.input-field::placeholder {
color: var(--text-muted);
} }
.select-field { .select-field {
@ -227,6 +320,10 @@ body {
border-radius: 6px; border-radius: 6px;
} }
[data-theme="dark"] .select-field {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23d1d5db' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
}
.input-field:focus + .input-label, .input-field:focus + .input-label,
.input-field:not(:placeholder-shown) + .input-label, .input-field:not(:placeholder-shown) + .input-label,
.select-field:focus + .input-label, .select-field:focus + .input-label,
@ -234,6 +331,7 @@ body {
top: 2px; top: 2px;
font-size: 12px; font-size: 12px;
transform: none; transform: none;
color: var(--accent-light);
} }
.checkbox-group { .checkbox-group {
@ -246,10 +344,13 @@ body {
.checkbox-group input { .checkbox-group input {
width: 16px; width: 16px;
height: 16px; height: 16px;
accent-color: var(--accent-color);
} }
.checkbox-group label { .checkbox-group label {
font-size: 14px; font-size: 14px;
color: var(--text-primary);
cursor: pointer;
} }
.form-actions { .form-actions {
@ -269,31 +370,33 @@ body {
} }
.btn-primary { .btn-primary {
background-color: #000000; background-color: var(--accent-color);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
opacity: 0.9; opacity: 0.9;
transform: translateY(-1px);
} }
.btn-secondary { .btn-secondary {
background-color: #f3f4f6; background-color: var(--bg-secondary);
color: #374151; color: var(--text-primary);
border: 1px solid var(--border-color);
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #e5e7eb; background-color: var(--border-color);
} }
.btn-outline { .btn-outline {
background-color: transparent; background-color: transparent;
border: 1px solid #070065; border: 1px solid var(--accent-light);
color: #070065; color: var(--accent-light);
} }
.btn-outline:hover { .btn-outline:hover {
background-color: #070065; background-color: var(--accent-light);
color: white; color: white;
} }
@ -301,6 +404,7 @@ body {
.btn:disabled { .btn:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none !important;
} }
.btn-sm { .btn-sm {
@ -317,31 +421,109 @@ body {
} }
.payment-option.selected { .payment-option.selected {
border-color: #000000; border-color: var(--border-color);
background-color: #f0f9ff; transition: all 0.3s ease;
background: var(--bg-card);
}
.payment-option:hover {
border-color: var(--accent-light);
transform: translateY(-1px);
}
.payment-option.selected {
border-color: var(--accent-color);
background-color: rgba(99, 102, 241, 0.1);
}
[data-theme="dark"] .payment-option.selected {
background-color: rgba(99, 102, 241, 0.2);
}
/* Button Dark Mode Styles */
[data-theme="dark"] .btn-primary {
background-color: #6366f1;
color: white;
border: 1px solid #6366f1;
}
[data-theme="dark"] .btn-primary:hover {
background-color: #4f46e5;
border-color: #4f46e5;
opacity: 1;
}
[data-theme="dark"] .btn-secondary {
background-color: #374151;
color: #d1d5db;
border: 1px solid #4b5563;
}
[data-theme="dark"] .btn-secondary:hover {
background-color: #4b5563;
border-color: #6b7280;
}
[data-theme="dark"] .btn-outline {
background-color: transparent;
border: 1px solid #6366f1;
color: #6366f1;
}
[data-theme="dark"] .btn-outline:hover {
background-color: #6366f1;
color: white;
}
[data-theme="dark"] .btn:disabled {
background-color: #374151;
color: #6b7280;
border-color: #4b5563;
opacity: 0.5;
}
/* Action button dark mode styles */
[data-theme="dark"] .password-toggle,
[data-theme="dark"] .password-generate,
[data-theme="dark"] .detect-location {
color: #818cf8;
}
[data-theme="dark"] .password-toggle:hover,
[data-theme="dark"] .password-generate:hover,
[data-theme="dark"] .detect-location:hover {
color: #6366f1;
}
/* Focus states for dark mode */
[data-theme="dark"] .btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 2px;
} }
.payment-option h4 { .payment-option h4 {
margin-bottom: 8px; margin-bottom: 8px;
font-size: 16px; font-size: 16px;
color: var(--text-primary);
} }
.payment-option p { .payment-option p {
font-size: 14px; font-size: 14px;
color: #6b7280; color: var(--text-secondary);
} }
.payment-details { .payment-details {
display: none; display: none;
margin: 4px 0 16px; margin: 4px 0 16px;
padding: 16px; padding: 16px;
border: 1px solid #e5e7eb; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
background-color: #bdbdbd; transition: all 0.3s ease;
} }
.payment-details.active { .payment-details.active {
display: block; display: block;
animation: slideDown 0.3s ease-out;
} }
.success-message { .success-message {
@ -351,24 +533,25 @@ body {
.success-icon { .success-icon {
font-size: 48px; font-size: 48px;
color: #10b981; color: var(--success-color);
margin-bottom: 16px; margin-bottom: 16px;
} }
.success-message h3 { .success-message h3 {
font-size: 24px; font-size: 24px;
margin-bottom: 8px; margin-bottom: 8px;
color: var(--text-primary);
} }
.success-message p { .success-message p {
color: #6b7280; color: var(--text-secondary);
margin-bottom: 24px; margin-bottom: 24px;
} }
.location-loading { .location-loading {
display: none; display: none;
font-size: 12px; font-size: 12px;
color: #6b7280; color: var(--text-secondary);
margin-top: 4px; margin-top: 4px;
margin-left: 12px; margin-left: 12px;
position: absolute; position: absolute;
@ -385,17 +568,17 @@ body {
} }
.profile-incomplete { .profile-incomplete {
background: #fff3cd; background: var(--warning-bg);
border: 1px solid #ffeaa7; border: 1px solid var(--warning-border);
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: #856404; color: var(--warning-text);
} }
.password-strength { .password-strength {
height: 5px; height: 5px;
background: #e9ecef; background: var(--bg-secondary);
border-radius: 5px; border-radius: 5px;
margin-top: 5px; margin-top: 5px;
overflow: hidden; overflow: hidden;
@ -409,7 +592,7 @@ body {
} }
.password-weak { .password-weak {
background: #dc3545; background: var(--error-color);
width: 25%; width: 25%;
} }
@ -419,7 +602,7 @@ body {
} }
.password-strong { .password-strong {
background: #28a745; background: var(--success-color);
width: 100%; width: 100%;
} }
@ -430,38 +613,134 @@ body {
margin-top: 8px; margin-top: 8px;
} }
.password-toggle { .password-toggle, .password-generate, .detect-location {
background: none; background: none;
border: none; border: none;
color: #070065; color: var(--accent-light);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
transition: color 0.3s ease;
} }
.password-generate { .password-toggle:hover, .password-generate:hover, .detect-location:hover {
background: none; color: var(--accent-color);
border: none;
color: #070065;
cursor: pointer;
font-size: 12px;
} }
.form-text { .form-text {
font-size: 12px; font-size: 12px;
color: #6b7280; color: var(--text-secondary);
margin-top: 4px; margin-top: 4px;
} }
.detect-location { /* Error States */
background: none; .step-error-container {
border: none; background-color: rgba(220, 38, 38, 0.1);
color: #070065; border: 1px solid rgba(220, 38, 38, 0.3);
cursor: pointer; border-radius: 8px;
font-size: 12px; padding: 12px 16px;
text-decoration: underline; margin-bottom: 20px;
margin-top: 5px; animation: slideDown 0.3s ease-out;
} }
[data-theme="dark"] .step-error-container {
background-color: rgba(220, 38, 38, 0.2);
border-color: rgba(220, 38, 38, 0.4);
}
.error-message {
color: var(--error-color);
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.error-message::before {
content: "⚠";
font-size: 16px;
}
.field-error {
border: 1px solid var(--error-color) !important;
background-color: rgba(220, 38, 38, 0.05) !important;
}
[data-theme="dark"] .field-error {
background-color: rgba(220, 38, 38, 0.1) !important;
}
.additional-info-heading {
margin-top: 24px;
margin-bottom: 16px;
border-top: 1px solid #e5e7eb;
padding-top: 10px;
}
/* Animations */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
[data-theme="dark"] ::-webkit-scrollbar-thumb {
background: var(--border-dark);
}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.btn:focus-visible,
.input-field:focus-visible,
.select-field:focus-visible,
.payment-option:focus-visible {
outline: 2px solid var(--accent-light);
outline-offset: 2px;
}
/* Selection Color */
::selection {
background-color: rgba(99, 102, 241, 0.3);
color: var(--text-primary);
}
/* Responsive styles */ /* Responsive styles */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.sidebar { .sidebar {
@ -477,41 +756,35 @@ body {
} }
} }
.step-error-container { @media (max-width: 1023px) {
background-color: #fef2f2; [data-theme="dark"] .mobile-progress {
border: 1px solid #fecaca; background-color: var(--bg-card);
border-radius: 8px; border: 1px solid var(--border-dark);
padding: 12px 16px;
margin-bottom: 20px;
animation: slideDown 0.3s ease-out;
}
.error-message {
color: #dc2626;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.error-message::before {
content: "⚠";
font-size: 16px;
}
.field-error {
border: 1px solid #dc2626 !important;
background-color: #fef2f2;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
} }
to {
opacity: 1; .mobile-progress .step {
transform: translateY(0); background: rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .mobile-progress .step {
background: rgba(255, 255, 255, 0.1);
}
}
@media print {
[data-theme="dark"] {
--bg-primary: #ffffff;
--bg-card: #ffffff;
--text-primary: #000000;
--text-secondary: #333333;
--border-color: #cccccc;
}
.sidebar {
display: none;
}
.btn {
display: none;
} }
} }

BIN
public/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" zoomAndPan="magnify" viewBox="0 0 600 599.999999" height="800" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="256ef070a6"><path d="M 23.410156 9.867188 L 578 9.867188 L 578 588 L 23.410156 588 Z M 23.410156 9.867188 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#256ef070a6)"><path fill="#060165" d="M 287.648438 9.867188 L 23.410156 9.867188 L 23.410156 321.480469 C 23.410156 468.195312 142.195312 587.019531 288.859375 587.019531 C 447.644531 587.019531 577.339844 457.28125 577.339844 298.445312 C 577.339844 139.605469 447.644531 9.867188 287.648438 9.867188 " fill-opacity="1" fill-rule="nonzero"/></g><path fill="#ffffff" d="M 237.949219 186.894531 C 237.949219 217.207031 212.496094 242.667969 182.195312 242.667969 C 151.890625 242.667969 126.4375 268.132812 126.4375 298.445312 C 126.4375 328.757812 151.890625 354.21875 182.195312 354.21875 L 293.707031 354.21875 C 324.011719 354.21875 349.464844 328.757812 349.464844 298.445312 L 349.464844 186.894531 C 349.464844 156.582031 324.011719 131.117188 293.707031 131.117188 C 263.40625 131.117188 237.949219 156.582031 237.949219 186.894531 " fill-opacity="1" fill-rule="nonzero"/><path fill="#05c3ec" d="M 361.585938 409.992188 C 361.585938 379.679688 387.039062 354.21875 417.34375 354.21875 C 447.644531 354.21875 473.097656 328.757812 473.097656 298.445312 C 473.097656 268.132812 447.644531 242.667969 417.34375 242.667969 L 305.828125 242.667969 C 275.527344 242.667969 250.070312 268.132812 250.070312 298.445312 L 250.070312 409.992188 C 250.070312 440.304688 275.527344 465.769531 305.828125 465.769531 C 336.132812 465.769531 361.585938 440.304688 361.585938 409.992188 " fill-opacity="1" fill-rule="nonzero"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

29
public/js/common/auth.js Normal file
View File

@ -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;
};

View File

@ -0,0 +1,39 @@
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');
}
});
});
document.addEventListener("DOMContentLoaded", () => {
const mobileMenuButton = document.querySelector(".mobile-menu-button");
const sidebar = document.querySelector(".dashboard-sidebar");
mobileMenuButton.addEventListener("click", () => {
sidebar.classList.toggle("active");
}
);
});

View File

@ -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);
});

View File

@ -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();
});
}
});

24
public/js/common/theme.js Normal file
View File

@ -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);
})();

View File

@ -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 `
<tr>
<td>
<div class="project-info">
<div class="project-name">${project.name}</div>
</div>
</td>
<td>
<span class="hourly-rate">$${project.hourlyRate}/hr</span>
</td>
<td>
<span class="worked-time">${project.totalWorkedTime}</span>
</td>
<td>
<div class="status-badge ${statusClass}">
<span class="status-text">${statusText}</span>
</div>
</td>
</tr>
`;
}
// 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 ? `<a href="/live/${va.lastProjectId}/${va.id}" class="view-link" data-va-id="${va.id}" target="_blank">Live Tracking</a>` : `<a class="va-logs va-action" data-va-id="${va.id}" target="_blank" href="/client/virtual-assistants/view-logs?va=${va.id}">Logs</a>`;
return `
<tr class="va-row" data-va-id="${va.id}">
<td>
<div class="status-indicator">
<span class="status-text ${statusClass}">${statusText}</span>
</div>
</td>
<td>
<div class="va-info">
<div class="va-name">${va.name}</div>
<div class="va-email">${va.email || 'N/A'}</div>
</div>
</td>
<td>
<span class="projects-count">${activeProject}</span>
</td>
<td>
<span class="projects-count">${projectsAssigned} Projects</span>
</td>
<td>
<span class="worked-time">${workedTime}</span>
</td>
<td>
${viewLink}
</td>
</tr>
`;
}
// 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 = `
<div class="empty-state">
<i class="fas fa-exclamation-triangle"></i>
<h3>Error</h3>
<p>${message}</p>
</div>
`;
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);
});

View File

@ -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 `
<tr>
<td>${project.name}</td>
<td>$${project.hourlyRate}/hr</td>
<td>${project.totalWorkedTime}</td>
<td>${project.isCurrentlyWorking ? "Working" : "Inactive"}</td>
</tr>
`;
}
function createVATableRow(va, index) {
return `
<tr class="va-row" data-index="${index}">
<td>
<span class="status ${va.isCurrentlyActive ? "active" : "inactive"}">
${va.isCurrentlyActive ? "Active" : "Inactive"}
</span>
</td>
<td>${va.name}</td>
<td>${va.lastProject?.name || "-"}</td>
<td>${va.projectsAssigned || 0}</td>
<td>${va.currentSessionDuration || "No Record Found"}</td>
<td>
${
va.isCurrentlyActive
? `<a href="/live/${va.lastProjectId}/${va.id}" target="_blank">Live</a>`
: "-"
}
</td>
</tr>
`;
}
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);

View File

@ -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 = `<button id='voidBtn' class='void-btn-a' data-id='${tip._id}'>Void</button>`;
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();
});

1241
public/js/pages/profile.js Normal file

File diff suppressed because it is too large Load Diff

240
public/js/pages/projects.js Normal file
View File

@ -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 `
<tr>
<td>
<div class="va-name">
<div class="va-avatar">${initials}</div>
<div class="va-details">
<span class="va-fullname">${va.firstName} ${va.lastName}</span>
<span class="va-email">${va.email}</span>
</div>
</div>
</td>
<td>
<div class=" ${statusClass}">
${statusText}
</div>
</td>
<td>
<div class="activity-status">
<div class="activity-dot ${activityClass}"></div>
<span>${activityText}</span>
</div>
</td>
</tr>
`;
}
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 `
<tr class="project-row" data-project-id="${project.id}">
<td>
<div class="project-info">
<div class="project-name">${project.name || 'Unnamed Project'}</div>
</div>
</td>
<td>
<span class="hourly-rate">${hourlyRate}</span>
</td>
<td>
<span class="va-count">${project.vaAssigned || 0}</span>
</td>
<td>
<span class="dedicated-hours">${dedicatedHours}</span>
</td>
</tr>
`;
}
// 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 = `
<div class="empty-state">
<i class="fas fa-exclamation-triangle"></i>
<h3>Error</h3>
<p>${message}</p>
</div>
`;
projectsEmptyState.style.display = 'block';
vaEmptyState.innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-triangle"></i>
<h3>Error</h3>
<p>${message}</p>
</div>
`;
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);

View File

@ -0,0 +1,61 @@
// API base URL
const API_BASE = '/api/v1/client';
const vaCreditsTableBody = document.getElementById("vaCreditTableBody");
const vaCreditsEmptyState = document.getElementById("vaEmptyCreditState");
function createCreditRow(credit) {
return `
<tr data-credit-id="${credit.id}">
<td><span class="status ${credit.status.toLowerCase()}">${credit.status}</span></td>
<td>${credit.date}</td>
<td>${credit.comment}</td>
<td>$${credit.creditAmount}</td>
</tr>
`;
}
async function loadCreditsData() {
const res = await fetchWithAuth(`${API_BASE}/credits`);
if (!res) return;
const result = await res.json();
if (!result.success) return;
console.log("Credits Result:", result);
const { data } = result;
console.log("Credits Data:", data);
const allCredits = data || [];
if (allCredits.length === 0) {
vaCreditsTableBody.innerHTML = "";
vaCreditsEmptyState.style.display = "block";
return;
}
vaCreditsEmptyState.style.display = "none";
vaCreditsTableBody.innerHTML = allCredits
.map((credit) => createCreditRow(credit))
.join("");
let totalCredits = 0;
let availableCredits = 0;
allCredits.forEach(credit => {
totalCredits += parseFloat(credit.creditAmount);
if (credit.status.toLowerCase() === 'pending') {
availableCredits += parseFloat(credit.creditAmount);
}
});
document.getElementById("vaTotalCredits").textContent = `$${totalCredits}`;
document.getElementById("vaAvailableCredits").textContent = `$${availableCredits}`;
}
// Initial load
loadCreditsData();

300
public/js/pages/va-logs.js Normal file
View File

@ -0,0 +1,300 @@
// API base URL
const API_BASE = '/api/v1/client';
function toggleAccordion(accordionId) {
const accordion = document.getElementById(accordionId);
accordion.classList.toggle('active');
// const allAccordions = document.querySelectorAll('.accordion');
// allAccordions.forEach(acc => {
// if (acc.id !== accordionId && acc.classList.contains('active')) {
// acc.classList.remove('active');
// }
// });
}
function formatTime(milliseconds) {
if (!milliseconds) return '0m';
const seconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds % 60}s`;
}
return `${minutes}m ${seconds % 60}s`;
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
function formatTimeOnly(dateString) {
const date = new Date(dateString);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function calculateTotalIndividualLogsHours(workTrackLogs) {
let total = 0;
workTrackLogs.forEach(workTrack => {
if (workTrack.logs && Array.isArray(workTrack.logs)) {
workTrack.logs.forEach(log => {
total += log.totalTime || 0;
});
}
});
return total;
}
function countIndividualLogs(workTrackLogs) {
let count = 0;
workTrackLogs.forEach(workTrack => {
if (workTrack.logs && Array.isArray(workTrack.logs)) {
count += workTrack.logs.length;
}
});
return count;
}
function calculateTotalHours(logs) {
return logs.reduce((total, log) => total + (log.totalTime || 0), 0);
}
// Create project accordion
function createProjectAccordion(projectData, index) {
const workTrackLogs = projectData.workTrackLogs || [];
const totalHours = calculateTotalIndividualLogsHours(workTrackLogs);
const individualLogsCount = countIndividualLogs(workTrackLogs);
const projectName = projectData.projectDetails?.name || `Project ${index + 1}`;
const accordionId = `project-${index}`;
return `
<div class="accordion" id="${accordionId}">
<div class="accordion-header" onclick="toggleAccordion('${accordionId}')">
<div class="accordion-title">
${projectName}
<span class="logs-count">${individualLogsCount} activities</span>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<div style="text-align: right;">
<div class="time-badge">${formatTime(totalHours)}</div>
</div>
<i class="fas fa-chevron-down accordion-icon"></i>
</div>
</div>
<div class="accordion-content">
${individualLogsCount > 0 ? createIndividualLogsTable(workTrackLogs) : createNoLogsMessage()}
</div>
</div>
`;
}
// Create table for individual log entries
function createIndividualLogsTable(workTrackLogs) {
// Collect all individual logs from all work tracks
const allIndividualLogs = [];
workTrackLogs.forEach(workTrack => {
if (workTrack.logs && Array.isArray(workTrack.logs)) {
workTrack.logs.forEach(log => {
allIndividualLogs.push(log);
});
}
});
return `
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Title</th>
<th>App Name</th>
<th>From</th>
<th>To</th>
<th>Total Time</th>
</tr>
</thead>
<tbody>
${allIndividualLogs.map((log, index) => createIndividualLogRow(log, index)).join('')}
</tbody>
</table>
</div>
`;
}
// Create a single row for individual log entry
function createIndividualLogRow(log, index) {
const title = log.title || `Activity ${index + 1}`;
const appName = log.appName || 'Unknown App';
// Parse dates
const fromDate = log.from;
const toDate = log.to || log.from;
// Calculate duration
let duration = log.totalTime || 0;
if (!duration && fromDate && toDate) {
const from = new Date(fromDate);
const to = new Date(toDate);
duration = Math.floor((to - from) / 1000);
}
return `
<tr>
<td>
<strong>${title}</strong>
</td>
<td>${appName}</td>
<td>
<div class="datetime-cell">
<div class="date-text">${formatDate(fromDate)}</div>
<div class="time-text">${formatTimeOnly(fromDate)}</div>
</div>
</td>
<td>
<div class="datetime-cell">
<div class="date-text">${formatDate(toDate)}</div>
<div class="time-text">${formatTimeOnly(toDate)}</div>
</div>
</td>
<td>
<span class="time-badge">${formatTime(duration)}</span>
</td>
</tr>
`;
}
// Create no logs message
function createNoLogsMessage() {
return `
<div style="text-align: center; padding: 2rem; color: #64748b;">
<i class="fas fa-clipboard"></i>
<h4>No Activity Logs</h4>
<p>No detailed activity logs available for this project.</p>
</div>
`;
}
// Load VA logs
async function loadVALogs() {
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
const projectsContainer = document.getElementById('projectsContainer');
try {
// Get VA ID from URL
const urlParams = new URLSearchParams(window.location.search);
const vaId = urlParams.get('va');
if (!vaId) {
window.location.href = '/client/virtual-assistants';
return;
}
// Fetch VA logs data
const response = await fetchWithAuth(`${API_BASE}/vaLogsData/${vaId}`);
if (!response || !response.ok) {
throw new Error('Failed to load VA logs');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.message || 'Failed to load VA logs');
}
// Hide loading state
loadingState.style.display = 'none';
const projectLogs = data.data?.projectLogs || [];
// Handle empty state
if (projectLogs.length === 0) {
emptyState.style.display = 'block';
projectsContainer.style.display = 'none';
return;
}
// Show projects container
projectsContainer.style.display = 'block';
emptyState.style.display = 'none';
// Create accordions for each project
let accordionsHTML = '';
projectLogs.forEach((project, index) => {
accordionsHTML += createProjectAccordion(project, index);
});
projectsContainer.innerHTML = accordionsHTML;
// Auto-open first accordion
if (projectLogs.length > 0) {
setTimeout(() => {
const firstAccordion = document.getElementById('project-0');
if (firstAccordion) {
firstAccordion.classList.add('active');
}
}, 100);
}
} catch (error) {
console.error("Error loading VA logs:", error);
loadingState.style.display = 'none';
projectsContainer.style.display = 'none';
// Show error in empty state
emptyState.innerHTML = `
<i class="fas fa-exclamation-triangle"></i>
<h3>Error Loading Data</h3>
<p>${error.message}</p>
<button onclick="loadVALogs()" class="btn btn-primary" style="margin-top: 1rem;">
<i class="fas fa-redo"></i> Retry
</button>
`;
emptyState.style.display = 'block';
}
}
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');
});
// 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);
// Load VA logs on page load
loadVALogs();
});

View File

@ -166,7 +166,7 @@ function validateCVV(cvv, cardType) {
} }
// ZIP code validation // ZIP code validation
function validateZipCode(zipCode) { function validateZipCodeRegex(zipCode) {
return /^\d{5}(-\d{4})?$/.test(zipCode); return /^\d{5}(-\d{4})?$/.test(zipCode);
} }
@ -450,7 +450,7 @@ async function validateStep(step) {
// ZIP code validation // ZIP code validation
const zipCode = document.getElementById('card-zip').value; const zipCode = document.getElementById('card-zip').value;
if (!validateZipCode(zipCode)) { if (!validateZipCodeRegex(zipCode)) {
isValid = false; isValid = false;
showError('Please enter a valid ZIP code', 'card-zip', step); showError('Please enter a valid ZIP code', 'card-zip', step);
return isValid; return isValid;
@ -491,10 +491,13 @@ async function validateStep(step) {
} }
} else if (step === 1) { } else if (step === 1) {
const email = document.getElementById('email').value; const email = document.getElementById('email').value;
const companyName = document.getElementById('company-name').value; const firstName = document.getElementById('first-name').value;
if (!validateEmail(email) && !companyName) { const lastName = document.getElementById('last-name').value;
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (!firstName && !lastName && !password && !confirmPassword) {
isValid = false; isValid = false;
showError('Both Fields are Required', null, step); showError('Fields are Required', null, step);
let requiredFields1 = form.querySelectorAll('[required]'); let requiredFields1 = form.querySelectorAll('[required]');
requiredFields1.forEach(field => { requiredFields1.forEach(field => {
if (!field.value.trim()) { if (!field.value.trim()) {
@ -516,9 +519,48 @@ async function validateStep(step) {
isValid = false; isValid = false;
} }
} }
if (!firstName) {
isValid = false;
showError('First Name is Required', 'first-name', step);
return isValid;
}
if (!lastName) {
isValid = false;
showError('Last Name is Required', 'last-name', step);
return isValid;
}
if (password.length < 8) {
showError('Password must be at least 8 characters long', 'password', step);
isValid = false;
}
if (password !== confirmPassword) {
document.getElementById('password-match-error').style.display = 'block';
document.getElementById('confirmPassword').classList.add('field-error');
showError('Passwords do not match', 'confirmPassword', step);
isValid = false;
} else {
document.getElementById('password-match-error').style.display = 'none';
document.getElementById('confirmPassword').classList.remove('field-error');
}
// Validate other required fields
const requiredFields = form.querySelectorAll('[required]');
requiredFields.forEach(field => {
if (field.id !== 'email' && !field.value.trim()) {
isValid = false;
field.classList.add('field-error');
} else if (field.id !== 'email') {
field.classList.remove('field-error');
}
});
} else if (step === 2) {
const companyName = document.getElementById('company-name').value;
if (!companyName) { if (!companyName) {
isValid = false; isValid = false;
showError('Company / Client Name is Required', null, step); showError('Company Name is Required', 'company-name', step);
return isValid; return isValid;
} }
@ -610,10 +652,13 @@ function getFormId(step) {
function saveStepData(step) { function saveStepData(step) {
switch(step) { switch(step) {
case 1: case 1:
formData.userDetails.name = document.getElementById('company-name').value; formData.userDetails.firstName = document.getElementById('first-name').value;
formData.userDetails.lastName = document.getElementById('last-name').value;
formData.userDetails.email = document.getElementById('email').value; formData.userDetails.email = document.getElementById('email').value;
formData.userDetails.password = document.getElementById('password').value;
break; break;
case 2: case 2:
formData.userDetails.name = document.getElementById('company-name').value;
formData.userDetails.address = document.getElementById('address').value; formData.userDetails.address = document.getElementById('address').value;
formData.userDetails.city = document.getElementById('city').value; formData.userDetails.city = document.getElementById('city').value;
formData.userDetails.state = document.getElementById('state').value; formData.userDetails.state = document.getElementById('state').value;
@ -690,17 +735,50 @@ async function submitForm() {
saveStepData(currentStep); saveStepData(currentStep);
// Save payment details based on selected method // Save payment details based on selected method
if (formData.userDetails.paymentMethod === 'card') { let cardNumber = document.getElementById('card-number');
formData.userDetails.cardNumber = document.getElementById('card-number').value; let expiryDate = document.getElementById('expiry-date');
formData.userDetails.expiryDate = document.getElementById('expiry-date').value; let securityCode = document.getElementById('security-code');
formData.userDetails.securityCode = document.getElementById('security-code').value; let cardZip = document.getElementById('card-zip');
formData.userDetails.cardZip = document.getElementById('card-zip').value; let bankName = document.getElementById('bank-name');
} else if (formData.userDetails.paymentMethod === 'bank') { let accountNumber = document.getElementById('account-number');
formData.userDetails.bankName = document.getElementById('bank-name').value; let routingNumber = document.getElementById('routing-number');
formData.userDetails.accountNumber = document.getElementById('account-number').value; let accountType = document.getElementById('account-type');
formData.userDetails.routingNumber = document.getElementById('routing-number').value; let accountHolderName = document.getElementById('account-holder-name');
formData.userDetails.accountType = document.getElementById('account-type').value;
formData.userDetails.accountHolderName = document.getElementById('account-holder-name').value; if(cardNumber.value) {
formData.userDetails.cardNumber = cardNumber.value;
}
if(expiryDate.value) {
formData.userDetails.expiryDate = expiryDate.value;
}
if(securityCode.value) {
formData.userDetails.securityCode = securityCode.value;
}
if(cardZip.value) {
formData.userDetails.cardZip = cardZip.value;
}
if(bankName.value) {
formData.userDetails.bankName = bankName.value;
}
if(accountNumber.value) {
formData.userDetails.accountNumber = accountNumber.value;
}
if(routingNumber.value) {
formData.userDetails.routingNumber = routingNumber.value;
}
if(accountType.value) {
formData.userDetails.accountType = accountType.value;
}
if(accountHolderName.value) {
formData.userDetails.accountHolderName = accountHolderName.value;
} }
// Show loading state // Show loading state
@ -734,7 +812,7 @@ async function submitForm() {
nextStep(6); nextStep(6);
setTimeout(() => { setTimeout(() => {
window.location.href = '/client/login'; window.location.href = '/';
}, 1000); }, 1000);
} else { } else {
// Handle server errors // Handle server errors
@ -824,28 +902,29 @@ async function detectLocation() {
await populateStates(countryCode, 'state'); await populateStates(countryCode, 'state');
await populateStates(countryCode, 'billing-state'); await populateStates(countryCode, 'billing-state');
const stateSelect = document.getElementById('state'); // auto populate state if region matches
const billingStateSelect = document.getElementById('billing-state'); // const stateSelect = document.getElementById('state');
// const billingStateSelect = document.getElementById('billing-state');
if (stateSelect && region) { // if (stateSelect && region) {
const matchedState = Array.from(stateSelect.options).find( // const matchedState = Array.from(stateSelect.options).find(
opt => opt.text.toLowerCase() === region.toLowerCase() // opt => opt.text.toLowerCase() === region.toLowerCase()
); // );
if (matchedState) { // if (matchedState) {
stateSelect.value = matchedState.value; // stateSelect.value = matchedState.value;
stateSelect.dispatchEvent(new Event('change')); // stateSelect.dispatchEvent(new Event('change'));
} // }
} // }
if (billingStateSelect && region) { // if (billingStateSelect && region) {
const matchedBillingState = Array.from(billingStateSelect.options).find( // const matchedBillingState = Array.from(billingStateSelect.options).find(
opt => opt.text.toLowerCase() === region.toLowerCase() // opt => opt.text.toLowerCase() === region.toLowerCase()
); // );
if (matchedBillingState) { // if (matchedBillingState) {
billingStateSelect.value = matchedBillingState.value; // billingStateSelect.value = matchedBillingState.value;
billingStateSelect.dispatchEvent(new Event('change')); // billingStateSelect.dispatchEvent(new Event('change'));
} // }
} // }
} catch (error) { } catch (error) {
console.error('Error detecting location:', error); console.error('Error detecting location:', error);
@ -1207,7 +1286,181 @@ function preventNumbers(event) {
return false; return false;
} }
// Function to validate company/client name // Function to validate first name
function validateFirstName(input) {
const errorElement = document.getElementById('first-name-error');
const value = input.value;
// Check various invalid patterns
const validationResult = validateFirstNameContent(value);
if (!validationResult.isValid && value.trim().length > 0) {
errorElement.textContent = validationResult.message;
errorElement.style.display = 'block';
input.classList.add('field-error');
return false;
} else {
errorElement.style.display = 'none';
input.classList.remove('field-error');
return true;
}
}
function validateFirstNameContent(firstName) {
const trimmed = firstName.trim();
if (trimmed.length === 0) {
return { isValid: false, message: 'First name is required' };
}
// Check minimum length
if (trimmed.length < 2) {
return { isValid: false, message: 'First name must be at least 2 characters' };
}
// Remove whitespace for content analysis
const content = trimmed.replace(/\s/g, '');
// Check if contains only special characters
if (/^[^a-zA-Z0-9]+$/.test(content)) {
return { isValid: false, message: 'First name cannot be only special characters' };
}
// Check if contains at least some valid content (letters or numbers)
if (!/[a-zA-Z0-9]/.test(content)) {
return { isValid: false, message: 'First name must contain letters or numbers' };
}
// Optional: Check for excessive special characters (more than 50%)
const specialCharCount = (content.match(/[^a-zA-Z0-9]/g) || []).length;
if (specialCharCount > content.length / 2) {
return { isValid: false, message: 'First name contains too many special characters' };
}
return { isValid: true, message: '' };
}
// Function to prevent invalid character input in real-time
function preventInvalidFirstNameChars(event) {
const charCode = event.which ? event.which : event.keyCode;
const key = event.key;
// Allow: letters, numbers, spaces, hyphens, apostrophes, ampersands, periods, commas
// Also allow common editing keys
if ((charCode >= 65 && charCode <= 90) || // A-Z
(charCode >= 97 && charCode <= 122) || // a-z
(charCode >= 48 && charCode <= 57) || // 0-9
charCode === 32 || // space
charCode === 45 || // hyphen
charCode === 39 || // apostrophe
charCode === 38 || // ampersand (&)
charCode === 46 || // period (.)
charCode === 44 || // comma (,)
charCode === 8 || // backspace
charCode === 9 || // tab
charCode === 37 || // left arrow
charCode === 39 || // right arrow
charCode === 16 || // shift
charCode === 17 || // ctrl
charCode === 18 || // alt
charCode === 91 || // cmd (Mac)
/^[a-zA-Z0-9\s\-'&.,]$/.test(key)) { // allowed characters
return true;
}
// Prevent invalid characters
event.preventDefault();
return false;
}
// Function to validate last name
function validateLastName(input) {
const errorElement = document.getElementById('last-name-error');
const value = input.value;
// Check various invalid patterns
const validationResult = validateLastNameContent(value);
if (!validationResult.isValid && value.trim().length > 0) {
errorElement.textContent = validationResult.message;
errorElement.style.display = 'block';
input.classList.add('field-error');
return false;
} else {
errorElement.style.display = 'none';
input.classList.remove('field-error');
return true;
}
}
function validateLastNameContent(lastName) {
const trimmed = lastName.trim();
if (trimmed.length === 0) {
return { isValid: false, message: 'Last name is required' };
}
// Check minimum length
if (trimmed.length < 2) {
return { isValid: false, message: 'Last name must be at least 2 characters' };
}
// Remove whitespace for content analysis
const content = trimmed.replace(/\s/g, '');
// Check if contains only special characters
if (/^[^a-zA-Z0-9]+$/.test(content)) {
return { isValid: false, message: 'Last name cannot be only special characters' };
}
// Check if contains at least some valid content (letters or numbers)
if (!/[a-zA-Z0-9]/.test(content)) {
return { isValid: false, message: 'Last name must contain letters or numbers' };
}
// Optional: Check for excessive special characters (more than 50%)
const specialCharCount = (content.match(/[^a-zA-Z0-9]/g) || []).length;
if (specialCharCount > content.length / 2) {
return { isValid: false, message: 'Last name contains too many special characters' };
}
return { isValid: true, message: '' };
}
// Function to prevent invalid character input in real-time
function preventInvalidLastNameChars(event) {
const charCode = event.which ? event.which : event.keyCode;
const key = event.key;
// Allow: letters, numbers, spaces, hyphens, apostrophes, ampersands, periods, commas
// Also allow common editing keys
if ((charCode >= 65 && charCode <= 90) || // A-Z
(charCode >= 97 && charCode <= 122) || // a-z
(charCode >= 48 && charCode <= 57) || // 0-9
charCode === 32 || // space
charCode === 45 || // hyphen
charCode === 39 || // apostrophe
charCode === 38 || // ampersand (&)
charCode === 46 || // period (.)
charCode === 44 || // comma (,)
charCode === 8 || // backspace
charCode === 9 || // tab
charCode === 37 || // left arrow
charCode === 39 || // right arrow
charCode === 16 || // shift
charCode === 17 || // ctrl
charCode === 18 || // alt
charCode === 91 || // cmd (Mac)
/^[a-zA-Z0-9\s\-'&.,]$/.test(key)) { // allowed characters
return true;
}
// Prevent invalid characters
event.preventDefault();
return false;
}
// Function to validate company name
function validateCompanyName(input) { function validateCompanyName(input) {
const errorElement = document.getElementById('company-name-error'); const errorElement = document.getElementById('company-name-error');
const value = input.value; const value = input.value;
@ -1262,7 +1515,7 @@ function validateCompanyNameContent(companyName) {
} }
// Function to prevent invalid character input in real-time // Function to prevent invalid character input in real-time
function preventInvalidCompanyChars(event) { function preventInvalidCompanyNameChars(event) {
const charCode = event.which ? event.which : event.keyCode; const charCode = event.which ? event.which : event.keyCode;
const key = event.key; const key = event.key;
@ -1922,3 +2175,51 @@ function preventInvalidAccountHolderChars(event) {
event.preventDefault(); event.preventDefault();
return false; return false;
} }
// Function to generate a strong password
function generatePassword() {
const length = 12;
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
let password = "";
// Ensure at least one of each required character type
password += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[Math.floor(Math.random() * 26)];
password += "abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 26)];
password += "0123456789"[Math.floor(Math.random() * 10)];
password += "!@#$%^&*"[Math.floor(Math.random() * 8)];
// Fill the rest randomly
for (let i = password.length; i < length; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
// Shuffle the password
password = password.split('').sort(() => 0.5 - Math.random()).join('');
// Set the password field
document.getElementById('password').value = password;
document.getElementById('confirmPassword').value = password;
// Trigger input events to update UI
document.getElementById('password').dispatchEvent(new Event('input'));
document.getElementById('confirmPassword').dispatchEvent(new Event('input'));
}
// Function to toggle password visibility
function togglePasswordVisibility() {
const passwordField = document.getElementById('password');
const confirmField = document.getElementById('confirmPassword');
const toggleButton = document.querySelector('.password-toggle');
if (passwordField.type === 'password') {
passwordField.type = 'text';
confirmField.type = 'text';
toggleButton.textContent = 'Hide Password';
} else {
passwordField.type = 'password';
confirmField.type = 'password';
toggleButton.textContent = 'Show Password';
}
}

View File

@ -9,69 +9,117 @@
// Countries and states data // Countries and states data
const countries = [ const countries = [
{ code: 'US', name: 'United States' }, { code: 'US', name: 'United States' },
{ code: 'CA', name: 'Canada' }, { code: 'CA', name: 'Canada' },
{ code: 'GB', name: 'United Kingdom' }, { code: 'GB', name: 'United Kingdom' },
{ code: 'AU', name: 'Australia' }, { code: 'AU', name: 'Australia' },
{ code: 'IN', name: 'India' }, { code: 'IN', name: 'India' },
{ code: 'DE', name: 'Germany' }, { code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' }, { code: 'FR', name: 'France' },
{ code: 'BR', name: 'Brazil' }, { code: 'BR', name: 'Brazil' },
{ code: 'JP', name: 'Japan' }, { code: 'JP', name: 'Japan' },
{ code: 'CN', name: 'China' } { code: 'CN', name: 'China' },
{ code: 'MX', name: 'Mexico' },
{ code: 'ES', name: 'Spain' },
{ code: 'IT', name: 'Italy' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'SE', name: 'Sweden' },
{ code: 'NO', name: 'Norway' },
{ code: 'DK', name: 'Denmark' },
{ code: 'FI', name: 'Finland' },
{ code: 'BE', name: 'Belgium' },
{ code: 'CH', name: 'Switzerland' },
{ code: 'AT', name: 'Austria' },
{ code: 'PT', name: 'Portugal' },
{ code: 'IE', name: 'Ireland' },
{ code: 'PL', name: 'Poland' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'HU', name: 'Hungary' },
{ code: 'RO', name: 'Romania' },
{ code: 'GR', name: 'Greece' },
{ code: 'TR', name: 'Turkey' },
{ code: 'SA', name: 'Saudi Arabia' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'ZA', name: 'South Africa' },
{ code: 'NG', name: 'Nigeria' },
{ code: 'EG', name: 'Egypt' },
{ code: 'KE', name: 'Kenya' },
{ code: 'AR', name: 'Argentina' },
{ code: 'CO', name: 'Colombia' },
{ code: 'PE', name: 'Peru' },
{ code: 'CL', name: 'Chile' },
{ code: 'VE', name: 'Venezuela' },
{ code: 'KR', name: 'South Korea' },
{ code: 'SG', name: 'Singapore' },
{ code: 'MY', name: 'Malaysia' },
{ code: 'TH', name: 'Thailand' },
{ code: 'ID', name: 'Indonesia' },
{ code: 'PH', name: 'Philippines' },
{ code: 'VN', name: 'Vietnam' },
{ code: 'PK', name: 'Pakistan' },
{ code: 'BD', name: 'Bangladesh' },
{ code: 'LK', name: 'Sri Lanka' },
{ code: 'NP', name: 'Nepal' },
{ code: 'RU', name: 'Russia' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'IL', name: 'Israel' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'HK', name: 'Hong Kong' },
{ code: 'TW', name: 'Taiwan' }
]; ];
const states = { // const states = {
'US': [ // 'US': [
{ code: 'CA', name: 'California' }, { code: 'TX', name: 'Texas' }, { code: 'FL', name: 'Florida' }, // { code: 'CA', name: 'California' }, { code: 'TX', name: 'Texas' }, { code: 'FL', name: 'Florida' },
{ code: 'NY', name: 'New York' }, { code: 'IL', name: 'Illinois' }, { code: 'PA', name: 'Pennsylvania' }, // { code: 'NY', name: 'New York' }, { code: 'IL', name: 'Illinois' }, { code: 'PA', name: 'Pennsylvania' },
{ code: 'OH', name: 'Ohio' }, { code: 'GA', name: 'Georgia' }, { code: 'NC', name: 'North Carolina' }, // { code: 'OH', name: 'Ohio' }, { code: 'GA', name: 'Georgia' }, { code: 'NC', name: 'North Carolina' },
{ code: 'MI', name: 'Michigan' } // { code: 'MI', name: 'Michigan' }
], // ],
'CA': [ // 'CA': [
{ code: 'ON', name: 'Ontario' }, { code: 'QC', name: 'Quebec' }, { code: 'BC', name: 'British Columbia' }, // { code: 'ON', name: 'Ontario' }, { code: 'QC', name: 'Quebec' }, { code: 'BC', name: 'British Columbia' },
{ code: 'AB', name: 'Alberta' }, { code: 'MB', name: 'Manitoba' }, { code: 'SK', name: 'Saskatchewan' } // { code: 'AB', name: 'Alberta' }, { code: 'MB', name: 'Manitoba' }, { code: 'SK', name: 'Saskatchewan' }
], // ],
'GB': [ // 'GB': [
{ code: 'ENG', name: 'England' }, { code: 'SCT', name: 'Scotland' }, { code: 'WLS', name: 'Wales' }, // { code: 'ENG', name: 'England' }, { code: 'SCT', name: 'Scotland' }, { code: 'WLS', name: 'Wales' },
{ code: 'NIR', name: 'Northern Ireland' } // { code: 'NIR', name: 'Northern Ireland' }
], // ],
'AU': [ // 'AU': [
{ code: 'NSW', name: 'New South Wales' }, { code: 'VIC', name: 'Victoria' }, { code: 'QLD', name: 'Queensland' }, // { code: 'NSW', name: 'New South Wales' }, { code: 'VIC', name: 'Victoria' }, { code: 'QLD', name: 'Queensland' },
{ code: 'WA', name: 'Western Australia' }, { code: 'SA', name: 'South Australia' }, { code: 'TAS', name: 'Tasmania' } // { code: 'WA', name: 'Western Australia' }, { code: 'SA', name: 'South Australia' }, { code: 'TAS', name: 'Tasmania' }
], // ],
'IN': [ // 'IN': [
{ code: 'MH', name: 'Maharashtra' }, { code: 'TN', name: 'Tamil Nadu' }, { code: 'UP', name: 'Uttar Pradesh' }, // { code: 'MH', name: 'Maharashtra' }, { code: 'TN', name: 'Tamil Nadu' }, { code: 'UP', name: 'Uttar Pradesh' },
{ code: 'KA', name: 'Karnataka' }, { code: 'GJ', name: 'Gujarat' }, { code: 'RJ', name: 'Rajasthan' } // { code: 'KA', name: 'Karnataka' }, { code: 'GJ', name: 'Gujarat' }, { code: 'RJ', name: 'Rajasthan' }
], // ],
'DE': [ // 'DE': [
{ code: 'BW', name: 'Baden-Württemberg' }, { code: 'BY', name: 'Bavaria' }, { code: 'BE', name: 'Berlin' }, // { code: 'BW', name: 'Baden-Württemberg' }, { code: 'BY', name: 'Bavaria' }, { code: 'BE', name: 'Berlin' },
{ code: 'HB', name: 'Bremen' }, { code: 'HH', name: 'Hamburg' }, { code: 'HE', name: 'Hesse' } // { code: 'HB', name: 'Bremen' }, { code: 'HH', name: 'Hamburg' }, { code: 'HE', name: 'Hesse' }
], // ],
'FR': [ // 'FR': [
{ code: 'ARA', name: 'Auvergne-Rhône-Alpes' }, { code: 'BFC', name: 'Bourgogne-Franche-Comté' }, // { code: 'ARA', name: 'Auvergne-Rhône-Alpes' }, { code: 'BFC', name: 'Bourgogne-Franche-Comté' },
{ code: 'BRE', name: 'Brittany' }, { code: 'CVL', name: 'Centre-Val de Loire' }, // { code: 'BRE', name: 'Brittany' }, { code: 'CVL', name: 'Centre-Val de Loire' },
{ code: 'COR', name: 'Corsica' }, { code: 'GES', name: 'Grand Est' } // { code: 'COR', name: 'Corsica' }, { code: 'GES', name: 'Grand Est' }
], // ],
'BR': [ // 'BR': [
{ code: 'SP', name: 'São Paulo' }, { code: 'RJ', name: 'Rio de Janeiro' }, { code: 'MG', name: 'Minas Gerais' }, // { code: 'SP', name: 'São Paulo' }, { code: 'RJ', name: 'Rio de Janeiro' }, { code: 'MG', name: 'Minas Gerais' },
{ code: 'BA', name: 'Bahia' }, { code: 'RS', name: 'Rio Grande do Sul' }, { code: 'PR', name: 'Paraná' } // { code: 'BA', name: 'Bahia' }, { code: 'RS', name: 'Rio Grande do Sul' }, { code: 'PR', name: 'Paraná' }
], // ],
'JP': [ // 'JP': [
{ code: 'TK', name: 'Tokyo' }, { code: 'OS', name: 'Osaka' }, { code: 'KY', name: 'Kyoto' }, // { code: 'TK', name: 'Tokyo' }, { code: 'OS', name: 'Osaka' }, { code: 'KY', name: 'Kyoto' },
{ code: 'HK', name: 'Hokkaido' }, { code: 'OK', name: 'Okinawa' }, { code: 'KN', name: 'Kanagawa' } // { code: 'HK', name: 'Hokkaido' }, { code: 'OK', name: 'Okinawa' }, { code: 'KN', name: 'Kanagawa' }
], // ],
'CN': [ // 'CN': [
{ code: 'BJ', name: 'Beijing' }, { code: 'SH', name: 'Shanghai' }, { code: 'TJ', name: 'Tianjin' }, // { code: 'BJ', name: 'Beijing' }, { code: 'SH', name: 'Shanghai' }, { code: 'TJ', name: 'Tianjin' },
{ code: 'CQ', name: 'Chongqing' }, { code: 'GD', name: 'Guangdong' }, { code: 'JS', name: 'Jiangsu' } // { code: 'CQ', name: 'Chongqing' }, { code: 'GD', name: 'Guangdong' }, { code: 'JS', name: 'Jiangsu' }
] // ]
}; // };
// Initialize the form // Initialize the form
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Populate country dropdown // Populate country dropdown
const countrySelect = document.getElementById('country'); const countrySelect = document.getElementById('country');
countrySelect.innerHTML = '<option value=""></option>'; countrySelect.innerHTML = '<option value="" disabled selected></option>';
countries.forEach(country => { countries.forEach(country => {
const option = document.createElement('option'); const option = document.createElement('option');
@ -80,32 +128,34 @@
countrySelect.appendChild(option); countrySelect.appendChild(option);
}); });
autoDetectCountry();
// Add event listener for country change // Add event listener for country change
countrySelect.addEventListener('change', function() { // countrySelect.addEventListener('change', function() {
const countryCode = this.value; // const countryCode = this.value;
const stateSelect = document.getElementById('state'); // const stateSelect = document.getElementById('state');
// Show loading // // Show loading
document.getElementById('state-loading').style.display = 'block'; // document.getElementById('state-loading').style.display = 'block';
// Clear current states // // Clear current states
stateSelect.innerHTML = '<option value=""></option>'; // stateSelect.innerHTML = '<option value="" disabled selected></option>';
// Simulate API delay // // Simulate API delay
setTimeout(() => { // setTimeout(() => {
if (countryCode && states[countryCode]) { // if (countryCode && states[countryCode]) {
states[countryCode].forEach(state => { // states[countryCode].forEach(state => {
const option = document.createElement('option'); // const option = document.createElement('option');
option.value = state.code; // option.value = state.code;
option.textContent = state.name; // option.textContent = state.name;
stateSelect.appendChild(option); // stateSelect.appendChild(option);
}); // });
} // }
// Hide loading // // Hide loading
document.getElementById('state-loading').style.display = 'none'; // document.getElementById('state-loading').style.display = 'none';
}, 500); // }, 500);
}); // });
// Add input event listeners to handle label animation // Add input event listeners to handle label animation
@ -209,6 +259,102 @@
}); });
}); });
async function autoDetectCountry() {
const countrySelect = document.getElementById('country');
// const loadingElement = document.getElementById('country-loading');
try {
// loadingElement.style.display = 'block';
// Using ipapi.co API (free tier available)
const response = await fetch('https://ipapi.co/json/');
if (!response.ok) {
throw new Error('Geolocation service unavailable');
}
const data = await response.json();
if (data && data.country_code) {
const detectedCountryCode = data.country_code;
const detectedCountryName = data.country_name;
// Find if the detected country is in our list
const countryExists = countries.find(country =>
country.code === detectedCountryCode
);
if (countryExists) {
// Set the country in dropdown
countrySelect.value = detectedCountryCode;
// Trigger the change event to update UI
countrySelect.dispatchEvent(new Event('change'));
// Show success message
console.log(`Auto-detected country: ${detectedCountryName}`);
} else {
console.log('Detected country not in supported list');
}
}
} catch (error) {
console.log('Could not auto-detect country:', error.message);
// Fallback: Use browser's geolocation API as backup
fallbackGeolocation();
} finally {
// loadingElement.style.display = 'none';
}
}
// Fallback geolocation using browser's navigator.geolocation
function fallbackGeolocation() {
if (!navigator.geolocation) {
console.log('Geolocation is not supported by this browser');
return;
}
navigator.geolocation.getCurrentPosition(
async function(position) {
try {
const { latitude, longitude } = position.coords;
// Use OpenStreetMap Nominatim API for reverse geocoding
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}`
);
if (response.ok) {
const data = await response.json();
if (data && data.address && data.address.country_code) {
const countryCode = data.address.country_code.toUpperCase();
const countrySelect = document.getElementById('country');
const countryExists = countries.find(country =>
country.code === countryCode
);
if (countryExists) {
countrySelect.value = countryCode;
countrySelect.dispatchEvent(new Event('change'));
console.log(`Fallback geolocation detected: ${data.address.country}`);
}
}
}
} catch (error) {
console.log('Fallback geolocation failed:', error);
}
},
function(error) {
console.log('Browser geolocation error:', error.message);
},
{
enableHighAccuracy: false,
timeout: 10000,
maximumAge: 600000 // 10 minutes
}
);
}
// Function to navigate to any step (for tab navigation) // Function to navigate to any step (for tab navigation)
function goToStep(step) { function goToStep(step) {
// Only allow navigation to completed steps or the next step // Only allow navigation to completed steps or the next step
@ -347,6 +493,14 @@
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value; const confirmPassword = document.getElementById('confirmPassword').value;
if(!email && !password) {
showError('', 'email', step);
showError('', 'password', step);
showError('Please enter a valid email address and password', 'confirmPassword', step);
isValid = false;
return isValid;
}
// Email validation // Email validation
if (!validateEmail(email)) { if (!validateEmail(email)) {
showError('Please enter a valid email address', 'email', step); showError('Please enter a valid email address', 'email', step);
@ -393,7 +547,7 @@
// Age validation // Age validation
const age = document.getElementById('age').value; const age = document.getElementById('age').value;
if (age && (age < 18 || age > 100)) { if (age && (age < 18 || age > 100)) {
showError('You age must be 18 years or older to register', 'dob', step); showError('You age must be 18 years or older to register', 'age', step);
isValid = false; isValid = false;
} }
@ -461,20 +615,35 @@
showError('Please select a payment method', null, step); showError('Please select a payment method', null, step);
isValid = false; isValid = false;
} else { } else {
const paymentMethod = selectedPayment.getAttribute('onclick').match(/'([^']+)'/)[1]; const paymentMethod = selectedPayment.getAttribute('onclick').match(/'([^']+)'/)[1];
if (paymentMethod === 'payoneer') { if (paymentMethod === 'payoneer') {
const payoneerAccount = document.getElementById('payoneerAccount').value; const payoneerAccount = document.getElementById('payoneerAccount').value;
if (!payoneerAccount) { if (!payoneerAccount) {
showError('Please enter your Payoneer account number', 'payoneerAccount', step); showError('Please enter your Payoneer account number', 'payoneerAccount', step);
isValid = false; isValid = false;
} } else if (!validateEmail(payoneerAccount)) {
} else if (paymentMethod === 'veem') { showError('Please enter a valid email address for Payoneer', 'payoneerAccount', step);
const veemAccount = document.getElementById('veemAccount').value; isValid = false;
if (!veemAccount) { }
showError('Please enter your Veem account number', 'veemAccount', step); } else if (paymentMethod === 'veem') {
isValid = false; const veemAccount = document.getElementById('veemAccount').value;
} if (!veemAccount) {
} showError('Please enter your Veem account number', 'veemAccount', step);
isValid = false;
} else if (!validateEmail(veemAccount)) {
showError('Please enter a valid email address for Veem', 'veemAccount', step);
isValid = false;
}
} else if (paymentMethod === 'wise') {
const wiseEmail = document.getElementById('wiseEmail').value;
if (!wiseEmail) {
showError('Please enter your Wise email address', 'wiseEmail', step);
isValid = false;
} else if (!validateEmail(wiseEmail)) {
showError('Please enter a valid email address for Wise', 'wiseEmail', step);
isValid = false;
}
}
} }
} }
@ -528,10 +697,11 @@
formData.userDetails.city = document.getElementById('city').value; formData.userDetails.city = document.getElementById('city').value;
formData.userDetails.state = document.getElementById('state').value; formData.userDetails.state = document.getElementById('state').value;
formData.userDetails.country = document.getElementById('country').value; formData.userDetails.country = document.getElementById('country').value;
formData.status = document.getElementById('status').value;
break; break;
case 3: case 3:
formData.userDetails.phone = document.getElementById('phone').value; formData.userDetails.phone = document.getElementById('phone').value;
formData.userDetails.skype = document.getElementById('skype').value; // formData.userDetails.skype = document.getElementById('skype').value;
break; break;
case 4: case 4:
const selectedPayment = document.querySelector('.payment-option.selected'); const selectedPayment = document.querySelector('.payment-option.selected');
@ -539,11 +709,15 @@
const paymentMethod = selectedPayment.getAttribute('onclick').match(/'([^']+)'/)[1]; const paymentMethod = selectedPayment.getAttribute('onclick').match(/'([^']+)'/)[1];
formData.userDetails.paymentMethod = paymentMethod; formData.userDetails.paymentMethod = paymentMethod;
if (paymentMethod === 'payoneer') { if (document.getElementById('payoneerAccount').value) {
formData.userDetails.payoneerAccount = document.getElementById('payoneerAccount').value; formData.userDetails.payoneerAccount = document.getElementById('payoneerAccount').value;
} else if (paymentMethod === 'veem') { }
if (document.getElementById('veemAccount').value) {
formData.userDetails.veemAccount = document.getElementById('veemAccount').value; formData.userDetails.veemAccount = document.getElementById('veemAccount').value;
} }
if (paymentMethod === 'wise') {
formData.userDetails.wiseEmail = document.getElementById('wiseEmail').value;
}
} }
break; break;
} }
@ -569,9 +743,74 @@
document.getElementById('payoneer-details').style.display = 'block'; document.getElementById('payoneer-details').style.display = 'block';
} else if (method === 'veem') { } else if (method === 'veem') {
document.getElementById('veem-details').style.display = 'block'; document.getElementById('veem-details').style.display = 'block';
} else if (method === 'wise') {
document.getElementById('wise-details').style.display = 'block';
} }
} }
function validateWiseEmail(input) {
const wiseError = document.getElementById('wise-email-error');
const value = input.value.trim();
// Remove any validation styling first
input.style.border = '';
wiseError.style.display = 'none';
if (value.length > 0) {
// Check if it's a valid email
if (!validateEmail(value)) {
wiseError.textContent = 'Please enter a valid email address for Wise';
wiseError.style.display = 'block';
input.style.border = '1px solid #ef4444';
return false;
}
}
return true;
}
function validateVeemEmail(input) {
const wiseError = document.getElementById('veem-error');
const value = input.value.trim();
// Remove any validation styling first
input.style.border = '';
wiseError.style.display = 'none';
if (value.length > 0) {
// Check if it's a valid email
if (!validateEmail(value)) {
wiseError.textContent = 'Please enter a valid email address for Veem';
wiseError.style.display = 'block';
input.style.border = '1px solid #ef4444';
return false;
}
}
return true;
}
function validatePayoneerEmail(input) {
const wiseError = document.getElementById('payoneer-error');
const value = input.value.trim();
// Remove any validation styling first
input.style.border = '';
wiseError.style.display = 'none';
if (value.length > 0) {
// Check if it's a valid email
if (!validateEmail(value)) {
wiseError.textContent = 'Please enter a valid email address for Payoneer';
wiseError.style.display = 'block';
input.style.border = '1px solid #ef4444';
return false;
}
}
return true;
}
// Function to generate a strong password // Function to generate a strong password
function generatePassword() { function generatePassword() {
const length = 12; const length = 12;
@ -742,11 +981,13 @@
// Validate age // Validate age
if (age < 18) { if (age < 18) {
// alert('You must be 18 years or older to register'); // alert('You must be 18 years or older to register');
showError('You Age must be 18 years or older to register', 'dob',); // showError('You Age must be 18 years or older to register', 'dob');
showError('You Age must be 18 years or older to register', 'age');
ageInput.style.border = '1px solid #ef4444'; ageInput.style.border = '1px solid #ef4444';
} else if (age > 100) { } else if (age > 100) {
// alert('Please enter a valid age'); // alert('Please enter a valid age');
showError('Please enter a valid age', 'dob'); // showError('Please enter a valid age', 'dob');
showError('Please enter a valid age', 'age');
ageInput.style.border = '1px solid #ef4444'; ageInput.style.border = '1px solid #ef4444';
} else { } else {
ageInput.style.border = ''; ageInput.style.border = '';
@ -811,18 +1052,37 @@
const payoneerError = document.getElementById('payoneer-error'); const payoneerError = document.getElementById('payoneer-error');
const value = input.value.trim(); const value = input.value.trim();
// Payoneer typically uses email format or alphanumeric IDs // Remove any validation styling first
// Adjust validation rules based on Payoneer's actual format requirements input.style.border = '';
if (value.length > 0 && (value.length < 5 || value.length > 20)) { payoneerError.style.display = 'none';
payoneerError.textContent = 'Payoneer account must be 5-20 characters';
payoneerError.style.display = 'block'; if (value.length > 0) {
input.style.border = '1px solid #ef4445'; // Check length
return false; if (value.length < 5 || value.length > 20) {
} else { payoneerError.textContent = 'Payoneer account must be 5-20 digits';
payoneerError.style.display = 'none'; payoneerError.style.display = 'block';
input.style.border = ''; input.style.border = '1px solid #ef4444';
return true; return false;
}
// Check if contains only digits
if (!/^\d+$/.test(value)) {
payoneerError.textContent = 'Payoneer account must contain digits only';
payoneerError.style.display = 'block';
input.style.border = '1px solid #ef4444';
return false;
}
// Check if all zeros
if (/^0+$/.test(value)) {
payoneerError.textContent = 'Payoneer account cannot be all zeros';
payoneerError.style.display = 'block';
input.style.border = '1px solid #ef4444';
return false;
}
} }
return true;
} }
// Function to validate Veem account number // Function to validate Veem account number
@ -830,17 +1090,37 @@
const veemError = document.getElementById('veem-error'); const veemError = document.getElementById('veem-error');
const value = input.value.trim(); const value = input.value.trim();
// Veem account numbers are typically longer alphanumeric strings // Remove any validation styling first
if (value.length > 0 && (value.length < 8 || value.length > 25)) { input.style.border = '';
veemError.textContent = 'Veem account must be 8-25 characters'; veemError.style.display = 'none';
veemError.style.display = 'block';
input.style.border = '1px solid #ef4445'; if (value.length > 0) {
return false; // Check length
} else { if (value.length < 8 || value.length > 25) {
veemError.style.display = 'none'; veemError.textContent = 'Veem account must be 8-25 digits';
input.style.border = ''; veemError.style.display = 'block';
return true; input.style.border = '1px solid #ef4444';
return false;
}
// Check if contains only digits
if (!/^\d+$/.test(value)) {
veemError.textContent = 'Veem account must contain digits only';
veemError.style.display = 'block';
input.style.border = '1px solid #ef4444';
return false;
}
// Check if all zeros
if (/^0+$/.test(value)) {
veemError.textContent = 'Veem account cannot be all zeros';
veemError.style.display = 'block';
input.style.border = '1px solid #ef4444';
return false;
}
} }
return true;
} }
function showError(message, fieldId = null, step = null) { function showError(message, fieldId = null, step = null) {
@ -902,3 +1182,27 @@
function getCurrentStep() { function getCurrentStep() {
return currentStep; return currentStep;
} }
function handleChangeSelect(selectElement) {
const selectedValue = selectElement.value;
let option = selectElement.querySelector('option:disabled');
if (selectedValue !== "") {
option.style.display = "none";
} else {
option.style.display = "";
}
}
function validateSkypeId(input) {
const value = input.value.trim();
if (value && !/^[a-zA-Z][a-zA-Z0-9\.,\-_]+$/.test(value)) {
input.style.border = '1px solid #ef4444';
showError('Invalid Skype ID', 'skype', 3);
return false;
} else {
input.style.border = '';
return true;
}
}

View File

@ -1,5 +1,6 @@
import { VAModel, LoggedModel, ClientModel, ProjectModel } from "../../../models/vaModel.js"; import { VAModel, LoggedModel, ClientModel, ProjectModel } from "../../../models/vaModel.js";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import getDayNumber from "../../../utils/getDayNumber.js";
// Getting client dashboard data - all VAs, projects, and active VAs // Getting client dashboard data - all VAs, projects, and active VAs
async function client_dashboard(req, res) { async function client_dashboard(req, res) {
@ -68,12 +69,7 @@ async function client_dashboard(req, res) {
$cond: [{ $eq: ["$loggedType", "LOGIN"] }, 1, 0] $cond: [{ $eq: ["$loggedType", "LOGIN"] }, 1, 0]
} }
}, },
lastActivity: { $first: "$time" }, lastActivity: { $first: "$time" }
projectsWorked: {
$addToSet: "$projectId"
},
// Get all login logs for this VA
allLogs: { $push: "$$ROOT" }
} }
}, },
{ {
@ -85,22 +81,12 @@ async function client_dashboard(req, res) {
const activeVaIds = agg.map((a) => a._id).filter(Boolean); const activeVaIds = agg.map((a) => a._id).filter(Boolean);
// Get active VA details
const activeVAs = await VAModel.find({
_id: { $in: activeVaIds },
clientIds: clientID
})
.select('userDetails.firstName userDetails.lastName userDetails.email userDetails.phone status createdAt projectIds')
.lean()
.exec();
// Create mapping for last log and activity data // Create mapping for last log and activity data
const activityByVa = agg.reduce((acc, a) => { const activityByVa = agg.reduce((acc, a) => {
acc[String(a._id)] = { acc[String(a._id)] = {
lastLog: a.lastLog, lastLog: a.lastLog,
totalLogins: a.totalLogins, totalLogins: a.totalLogins,
lastActivity: a.lastActivity, lastActivity: a.lastActivity
projectsWorked: a.projectsWorked || []
}; };
return acc; return acc;
}, {}); }, {});
@ -126,14 +112,14 @@ async function client_dashboard(req, res) {
logsByVa[vaId].push(log); logsByVa[vaId].push(log);
}); });
// Calculate total worked time for each VA using the same logic as va_log_work_report_callback // Calculate total worked time for each VA
for (const [vaId, logs] of Object.entries(logsByVa)) { for (const [vaId, logs] of Object.entries(logsByVa)) {
let totalWorkSeconds = 0; let totalWorkSeconds = 0;
// Group logs by date (YYYY-MM-DD format) // Group logs by date (YYYY-MM-DD format)
const logsByDate = {}; const logsByDate = {};
logs.forEach(log => { logs.forEach(log => {
const date = new Date(log.time).toISOString().split('T')[0]; // YYYY-MM-DD const date = new Date(log.time).toISOString().split('T')[0];
if (!logsByDate[date]) { if (!logsByDate[date]) {
logsByDate[date] = []; logsByDate[date] = [];
} }
@ -147,7 +133,6 @@ async function client_dashboard(req, res) {
let dayWorkSeconds = 0; let dayWorkSeconds = 0;
let lastLogin = null; let lastLogin = null;
let lastProjectId = null;
// If day has adjustment logs, only process adjustment logs // If day has adjustment logs, only process adjustment logs
// If no adjustment logs, process all logs for that day // If no adjustment logs, process all logs for that day
@ -164,14 +149,10 @@ async function client_dashboard(req, res) {
if (log.loggedType === "LOGIN") { if (log.loggedType === "LOGIN") {
lastLogin = time; lastLogin = time;
lastProjectId = log.projectId?.toString() || null;
} else if (log.loggedType === "LOGOUT" && lastLogin) { } else if (log.loggedType === "LOGOUT" && lastLogin) {
const diffSec = (time - lastLogin) / 1000; const diffSec = (time - lastLogin) / 1000;
dayWorkSeconds += diffSec; dayWorkSeconds += diffSec;
// reset after each pair
lastLogin = null; lastLogin = null;
lastProjectId = null;
} }
} }
@ -184,25 +165,93 @@ async function client_dashboard(req, res) {
}; };
} }
// NEW: Calculate project-wise work time for each VA
const projectWorkTimeByVa = {};
for (const [vaId, logs] of Object.entries(logsByVa)) {
const projectWorkTime = {};
// Group logs by project
const logsByProject = {};
logs.forEach(log => {
const projectId = log.projectId?.toString();
if (projectId) {
if (!logsByProject[projectId]) {
logsByProject[projectId] = [];
}
logsByProject[projectId].push(log);
}
});
// Calculate work time per project
for (const [projectId, projectLogs] of Object.entries(logsByProject)) {
let projectWorkSeconds = 0;
// Group logs by date
const logsByDate = {};
projectLogs.forEach(log => {
const date = new Date(log.time).toISOString().split('T')[0];
if (!logsByDate[date]) {
logsByDate[date] = [];
}
logsByDate[date].push(log);
});
// Process each day separately
for (const [date, dayLogs] of Object.entries(logsByDate)) {
const hasAdjustmentLogs = dayLogs.some(log => log.isAdjustment === true);
let dayWorkSeconds = 0;
let lastLogin = null;
const logsToProcess = hasAdjustmentLogs
? dayLogs.filter(log => log.isAdjustment === true)
: dayLogs;
logsToProcess.sort((a, b) => new Date(a.time) - new Date(b.time));
for (const log of logsToProcess) {
const time = new Date(log.time);
if (log.loggedType === "LOGIN") {
lastLogin = time;
} else if (log.loggedType === "LOGOUT" && lastLogin) {
const diffSec = (time - lastLogin) / 1000;
dayWorkSeconds += diffSec;
lastLogin = null;
}
}
projectWorkSeconds += dayWorkSeconds;
}
projectWorkTime[projectId] = {
totalSeconds: projectWorkSeconds,
formatted: formatTime(projectWorkSeconds),
projectName: projectMap[projectId]?.name || 'Unknown Project'
};
}
projectWorkTimeByVa[vaId] = projectWorkTime;
}
// Calculate session duration for currently active VAs // Calculate session duration for currently active VAs
const currentTime = new Date(); const currentTime = new Date();
// Format all assigned VAs // Format all assigned VAs - Only include data needed for frontend
const allVAsFormatted = allAssignedVAs.map((v) => { const allVAsFormatted = allAssignedVAs.map((v) => {
const activity = activityByVa[String(v._id)] || {}; const activity = activityByVa[String(v._id)] || {};
const workedTime = totalWorkedTimeByVa[String(v._id)] || { totalSeconds: 0, formatted: '00:00:00' }; const workedTime = totalWorkedTimeByVa[String(v._id)] || { totalSeconds: 0, formatted: '00:00:00' };
const lastLog = activity.lastLog; const lastLog = activity.lastLog;
const projectWorkTime = projectWorkTimeByVa[String(v._id)] || {};
// Calculate current session duration if still logged in // Calculate current session duration if still logged in
let currentSessionDuration = null; let currentSessionDuration = null;
let isCurrentlyActive = false; let isCurrentlyActive = false;
let currentSessionFormatted = null;
let currentProjectDetails = null; let currentProjectDetails = null;
if (lastLog && lastLog.loggedType === "LOGIN") { if (lastLog && lastLog.loggedType === "LOGIN") {
const loginTime = new Date(lastLog.time); const loginTime = new Date(lastLog.time);
currentSessionDuration = Math.floor((currentTime - loginTime) / 1000); currentSessionDuration = Math.floor((currentTime - loginTime) / 1000);
currentSessionFormatted = formatTime(currentSessionDuration);
isCurrentlyActive = true; isCurrentlyActive = true;
// Consider VA inactive if last login was more than 24 hours ago // Consider VA inactive if last login was more than 24 hours ago
@ -223,6 +272,29 @@ async function client_dashboard(req, res) {
// Combine first and last name // Combine first and last name
const fullName = `${v.userDetails?.firstName || ''} ${v.userDetails?.lastName || ''}`.trim() || 'Unknown'; const fullName = `${v.userDetails?.firstName || ''} ${v.userDetails?.lastName || ''}`.trim() || 'Unknown';
// Filter: Only include projects that belong to this client
const clientProjectsAssigned = (v.projectIds || []).filter(projectId =>
clientProjectIds.some(clientProjectId =>
clientProjectId.toString() === projectId.toString()
)
);
// Format project work time for popup
const projectsWithWorkTime = clientProjectsAssigned.map(projectId => {
const projectIdStr = projectId.toString();
const workData = projectWorkTime[projectIdStr] || { totalSeconds: 0, formatted: '00:00:00' };
const project = projectMap[projectIdStr];
return {
id: projectId,
name: project?.name || 'Unknown Project',
totalWorkedTime: workData.formatted,
totalWorkedSeconds: workData.totalSeconds,
isCurrentlyWorking: currentProjectDetails?.id?.toString() === projectIdStr,
hourlyRate: project?.hourlyRate || 0
};
});
return { return {
id: v._id, id: v._id,
name: fullName, name: fullName,
@ -231,102 +303,23 @@ async function client_dashboard(req, res) {
status: v.status || 'active', status: v.status || 'active',
isCurrentlyActive: isCurrentlyActive, isCurrentlyActive: isCurrentlyActive,
lastLoginTime: lastLog?.time || null, lastLoginTime: lastLog?.time || null,
lastActivity: activity.lastActivity,
currentSessionDuration: currentSessionDuration, currentSessionDuration: currentSessionDuration,
currentSessionFormatted: currentSessionFormatted,
totalWorkedTime: workedTime.formatted, totalWorkedTime: workedTime.formatted,
totalWorkedSeconds: workedTime.totalSeconds, totalWorkedSeconds: workedTime.totalSeconds,
totalLogins: activity.totalLogins || 0,
lastProject: currentProjectDetails, lastProject: currentProjectDetails,
lastProjectId: lastLog?.projectId || null, lastProjectId: lastLog?.projectId || null,
projectsWorked: activity.projectsWorked || [], projectsAssigned: clientProjectsAssigned.length,
projectsAssigned: v.projectIds?.length || 0, // Detailed project information for popup
projects: projectsWithWorkTime,
vaSince: v.createdAt vaSince: v.createdAt
}; };
}); });
// Format active VAs (currently working) const dayNumber = getDayNumber();
const activeVAsFormatted = activeVAs.map((v) => {
const activity = activityByVa[String(v._id)] || {};
const workedTime = totalWorkedTimeByVa[String(v._id)] || { totalSeconds: 0, formatted: '00:00:00' };
const lastLog = activity.lastLog;
let currentSessionDuration = null;
let currentSessionFormatted = null;
let currentProjectDetails = null;
if (lastLog && lastLog.loggedType === "LOGIN") {
const loginTime = new Date(lastLog.time);
currentSessionDuration = Math.floor((currentTime - loginTime) / 1000);
currentSessionFormatted = formatTime(currentSessionDuration);
// Get current project details
if (lastLog.projectId && projectMap[lastLog.projectId.toString()]) {
currentProjectDetails = {
id: lastLog.projectId,
name: projectMap[lastLog.projectId.toString()].name
};
}
}
// Combine first and last name
const fullName = `${v.userDetails?.firstName || ''} ${v.userDetails?.lastName || ''}`.trim() || 'Unknown';
return {
id: v._id,
name: fullName,
email: v.userDetails?.email,
phone: v.userDetails?.phone,
status: v.status || 'active',
lastLoginTime: lastLog?.time || null,
currentSessionDuration: currentSessionDuration,
currentSessionFormatted: currentSessionFormatted,
totalWorkedTime: workedTime.formatted,
totalWorkedSeconds: workedTime.totalSeconds,
totalLogins: activity.totalLogins || 0,
currentProject: currentProjectDetails,
currentProjectId: lastLog?.projectId || null,
projectsAssigned: v.projectIds?.length || 0,
vaSince: v.createdAt
};
});
// Format projects with additional data
const projectsFormatted = clientProjects.map((p) => {
// Find VAs currently working on this project
const activeVAsOnProject = allVAsFormatted.filter(va =>
va.isCurrentlyActive && va.lastProjectId &&
va.lastProjectId.toString() === p._id.toString()
);
// Find all VAs assigned to this project
const assignedVAs = allAssignedVAs.filter(va =>
va.projectIds && va.projectIds.some(projectId =>
projectId.toString() === p._id.toString()
)
);
return {
id: p._id,
name: p.name,
hourlyRate: p.hourlyRate,
totalLoggedHours: p.totalLoggedHours || 0,
dedicatedHours: p.dedicatedHours || 0,
vaAssigned: assignedVAs.length,
vaCurrentlyWorking: activeVAsOnProject.length,
activeVAs: activeVAsOnProject.map(va => ({
id: va.id,
name: va.name,
currentSession: va.currentSessionFormatted,
lastLoginTime: va.lastLoginTime
})),
createdAt: p.createdAt
};
});
// Calculate dashboard statistics // Calculate dashboard statistics
const totalHoursLogged = clientProjects.reduce((sum, p) => sum + (p.totalLoggedHours || 0), 0); const totalHoursLogged = clientProjects.reduce((sum, p) => sum + (p.totalLoggedHours || 0), 0);
const totalDedicatedHours = clientProjects.reduce((sum, p) => sum + (p.dedicatedHours || 0), 0); const totalDedicatedHours = clientProjects.reduce((sum, p) => sum + (p.dedicatedHours[dayNumber] || 0), 0);
const currentlyActiveVAs = allVAsFormatted.filter(va => va.isCurrentlyActive); const currentlyActiveVAs = allVAsFormatted.filter(va => va.isCurrentlyActive);
const inactiveVAs = allVAsFormatted.filter(va => !va.isCurrentlyActive); const inactiveVAs = allVAsFormatted.filter(va => !va.isCurrentlyActive);
@ -334,45 +327,22 @@ async function client_dashboard(req, res) {
// Calculate total worked time across all VAs // Calculate total worked time across all VAs
const totalWorkedTimeAllVAs = allVAsFormatted.reduce((total, va) => total + (va.totalWorkedSeconds || 0), 0); const totalWorkedTimeAllVAs = allVAsFormatted.reduce((total, va) => total + (va.totalWorkedSeconds || 0), 0);
// Calculate projects with active workers
const projectsWithActiveWorkers = projectsFormatted.filter(p => p.vaCurrentlyWorking > 0).length;
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
data: { data: {
// All assigned VAs with their status // Only send data that frontend actually uses
allVAs: { allVAs: allVAsFormatted
active: currentlyActiveVAs,
inactive: inactiveVAs,
total: allVAsFormatted
},
// Currently working VAs (active session)
activeVAs: activeVAsFormatted,
// All client projects
projects: projectsFormatted
}, },
summary: { summary: {
totalVAs: allAssignedVAs.length, totalVAs: allAssignedVAs.length,
activeVAs: currentlyActiveVAs.length, activeVAs: currentlyActiveVAs.length,
inactiveVAs: inactiveVAs.length, inactiveVAs: inactiveVAs.length,
totalProjects: clientProjects.length, totalProjects: clientProjects.length,
projectsWithActiveWorkers: projectsWithActiveWorkers,
totalHoursLogged: totalHoursLogged, totalHoursLogged: totalHoursLogged,
totalDedicatedHours: totalDedicatedHours, totalDedicatedHours: totalDedicatedHours,
totalWorkedTime: formatTime(totalWorkedTimeAllVAs), totalWorkedTime: formatTime(totalWorkedTimeAllVAs),
utilizationRate: totalDedicatedHours > 0 ? utilizationRate: totalDedicatedHours > 0 ?
((totalHoursLogged / totalDedicatedHours) * 100).toFixed(2) + '%' : '0%', ((totalHoursLogged / totalDedicatedHours) * 100).toFixed(2) + '%' : '0%'
lastUpdated: currentTime.toISOString()
},
counts: {
vas: {
total: allAssignedVAs.length,
active: currentlyActiveVAs.length,
inactive: inactiveVAs.length
},
projects: clientProjects.length,
activeSessions: activeVAsFormatted.length,
projectsWithActivity: projectsWithActiveWorkers
} }
}); });
@ -386,7 +356,7 @@ async function client_dashboard(req, res) {
} }
} }
// Helper function to format time (same as in va_log_work_report_callback) // Helper function to format time
function formatTime(seconds) { function formatTime(seconds) {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);

View File

@ -0,0 +1,40 @@
import authFetch from "../../../utils/authFetch.js";
async function va_credits_get(req, res) {
try {
const clientId = req.body.clientId;
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/credit/${clientId}`;
const response = await authFetch(salesforceUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
// Add any required headers for Salesforce authentication
// 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
},
});
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);
res.status(201).json({
success: true,
data: result.data
});
} 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;
}
}
export { va_credits_get };

View File

@ -0,0 +1,117 @@
import { VAModel, ClientModel, ProjectModel, WorkTrackModel } from "../../../models/vaModel.js";
async function clientDashboardGetVaLogsController(req, res) {
try {
const clientId = req.body.clientId;
const clientUser = await ClientModel.findById(clientId);
if (!clientUser) {
return res.status(404).json({ success: false, message: "Client not found" });
}
const { vaId } = req.params;
const vaUser = await VAModel.findById(vaId);
if (!vaUser) {
return res.status(404).json({ success: false, message: "Virtual Assistant not found" });
}
// Get shared active projects
const sharedProjects = await ProjectModel.find({
clientId: clientUser._id,
'vaAssigned.vaId': vaId,
status: "ACTIVE"
}).select('name');
if (!sharedProjects || sharedProjects.length === 0) {
return res.status(200).json({
success: true,
message: "No shared projects found",
data: { projectLogs: [] }
});
}
const projectIds = sharedProjects.map(p => p._id);
// Get work tracks for the last 15 days
const fifteenDaysAgo = new Date();
fifteenDaysAgo.setDate(fifteenDaysAgo.getDate() - 15);
// Get all work tracks
const workTracks = await WorkTrackModel.find({
vaId: vaId,
projectId: { $in: projectIds },
from: { $gte: fifteenDaysAgo }
})
.sort({ from: -1 })
.lean();
// Build project logs array
const projectLogs = [];
let totalWorkTracks = 0;
let totalHours = 0;
sharedProjects.forEach(project => {
// Filter tracks for this project
const projectTracks = workTracks.filter(track =>
track.projectId.toString() === project._id.toString()
);
// Only add project if it has tracks
if (projectTracks.length > 0) {
// Calculate project total hours
const projectTotalHours = projectTracks.reduce((sum, track) => sum + (track.totalTime || 0), 0);
projectLogs.push({
projectDetails: {
id: project._id,
name: project.name,
hourlyRate: project.hourlyRate
},
workTrackLogs: projectTracks.map(track => ({
id: track._id,
from: track.from,
to: track.to,
totalTime: track.totalTime,
logs: track.logs || [],
createdAt: track.createdAt,
updatedAt: track.updatedAt
}))
});
totalWorkTracks += projectTracks.length;
totalHours += projectTotalHours;
}
});
const response = {
success: true,
message: "VA logs retrieved successfully",
data: {
projectLogs: projectLogs
}
};
// Add summary if needed (optional)
if (projectLogs.length > 0) {
response.data.summary = {
totalProjects: projectLogs.length,
totalWorkTracks: totalWorkTracks,
totalHours: totalHours,
dateRange: {
startDate: fifteenDaysAgo.toISOString().split('T')[0],
endDate: new Date().toISOString().split('T')[0]
}
};
}
return res.status(200).json(response);
} catch (error) {
console.error('Error fetching VA logs:', error);
return res.status(500).json({
success: false,
message: 'Failed to load VA logs'
});
}
}
export { clientDashboardGetVaLogsController };

View File

@ -1,5 +1,6 @@
import { ClientModel, ProjectModel, VAModel } from "../../../models/vaModel.js"; import { ClientModel, ProjectModel, VAModel, LoggedModel } from "../../../models/vaModel.js";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import getDayNumber from "../../../utils/getDayNumber.js";
function formatHoursToReadable(hours) { function formatHoursToReadable(hours) {
const wholeHours = Math.floor(hours); const wholeHours = Math.floor(hours);
@ -15,7 +16,7 @@ function formatHoursToReadable(hours) {
} }
} }
// Getting client dashboard data - all VAs and projects // Getting client dashboard data - projects with assigned VA details
async function client_dashboard_projects(req, res) { async function client_dashboard_projects(req, res) {
try { try {
const accessToken = req.headers.token; const accessToken = req.headers.token;
@ -31,64 +32,139 @@ async function client_dashboard_projects(req, res) {
} }
const clientEmail = decoded.email; const clientEmail = decoded.email;
// STEP 1: Get client ID from token
const clientUser = await ClientModel.findOne({ "userDetails.email": clientEmail }); const clientUser = await ClientModel.findOne({ "userDetails.email": clientEmail });
if (!clientUser) { if (!clientUser) {
return res.status(404).json({ success: false, message: "Client not found" }); return res.status(404).json({ success: false, message: "Client not found" });
} }
// Get all VA IDs and Project IDs assigned to this client const clientId = clientUser._id;
const clientVaIds = clientUser.vaIds || [];
const clientProjectIds = clientUser.projectIds || [];
// Get all VAs assigned to this client (with required fields only) // STEP 2: Find all projects of this client
const allAssignedVAs = await VAModel.find({
_id: { $in: clientVaIds }
})
.select('userDetails.firstName userDetails.lastName userDetails.email status')
.lean()
.exec();
// Get All Projects of client with required fields
const clientProjects = await ProjectModel.find({ const clientProjects = await ProjectModel.find({
_id: { $in: clientProjectIds } clientId: clientId
}) })
.select('name hourlyRate vaAssigned dedicatedHours status') .select('name hourlyRate vaAssigned dedicatedHours status')
.lean() .lean()
.exec(); .exec();
// Format VAs with only required fields // Get all VA IDs from all projects for activity check
const vasFormatted = allAssignedVAs.map((va) => ({ const allProjectVaIds = [];
id: va._id, clientProjects.forEach(project => {
firstName: va.userDetails?.firstName || '', if (project.vaAssigned && project.vaAssigned.length > 0) {
lastName: va.userDetails?.lastName || '', allProjectVaIds.push(...project.vaAssigned);
email: va.userDetails?.email || '', }
status: va.status || '' });
// STEP 3: Get all VAs assigned to these projects
const allAssignedVAs = await VAModel.find({
_id: { $in: allProjectVaIds.vaId }
})
.select('userDetails.firstName userDetails.lastName userDetails.email status')
.lean()
.exec();
// Create a map for quick VA lookup
const vaMap = allAssignedVAs.reduce((acc, va) => {
acc[va._id.toString()] = va;
return acc;
}, {});
// STEP 4: Check VA activity per project
const currentTime = new Date();
// Format projects with assigned VA details
const projectsFormatted = await Promise.all(clientProjects.map(async (project) => {
const assignedVAIds = project.vaAssigned || [];
// Get VA details and check activity for each assigned VA
const assignedVADetails = await Promise.all(assignedVAIds.map(async (vaId) => {
const va = vaMap[vaId.toString()];
if (!va) return null;
// Check if this VA is currently active on THIS specific project
const latestLog = await LoggedModel.findOne({
vaId: vaId,
projectId: project._id,
loggedType: "LOGIN"
}).sort({ time: -1 }).lean();
let isActive = false;
if (latestLog) {
const loginTime = new Date(latestLog.time);
const hoursSinceLastActivity = (currentTime - loginTime) / (1000 * 60 * 60);
if (hoursSinceLastActivity <= 24) {
isActive = true;
}
}
return {
id: va._id,
firstName: va.userDetails?.firstName || '',
lastName: va.userDetails?.lastName || '',
email: va.userDetails?.email || '',
status: va.status || '',
isActive: isActive
};
}));
// Filter out null entries and count active VAs
const validVADetails = assignedVADetails.filter(Boolean);
const activeVAsCount = validVADetails.filter(va => va.isActive).length;
const dayNumber = getDayNumber();
return {
id: project._id,
name: project.name,
hourlyRate: project.hourlyRate,
vaAssigned: validVADetails.length,
activeVAs: activeVAsCount,
dedicatedHours: formatHoursToReadable(project.dedicatedHours[dayNumber]) || "0h",
status: project.status || '',
assignedVADetails: validVADetails
};
})); }));
// Format projects with only required fields // Calculate summary counts
const projectsFormatted = clientProjects.map((project) => ({ const totalProjects = projectsFormatted.length;
id: project._id,
name: project.name, // Count total unique VAs across all projects
hourlyRate: project.hourlyRate, const allVAsAcrossProjects = new Set();
vaAssigned: project.vaAssigned.length || 0, projectsFormatted.forEach(project => {
dedicatedHours: formatHoursToReadable(project.dedicatedHours) || 0, project.assignedVADetails.forEach(va => {
status: project.status || '' allVAsAcrossProjects.add(va.id);
})); });
});
// Count total active VAs (across all projects)
const totalActiveVAs = new Set();
projectsFormatted.forEach(project => {
project.assignedVADetails.forEach(va => {
if (va.isActive) {
totalActiveVAs.add(va.id);
}
});
});
const projectsWithActiveVAs = projectsFormatted.filter(project => project.activeVAs > 0).length;
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
data: { data: {
vas: vasFormatted,
projects: projectsFormatted projects: projectsFormatted
}, },
counts: { counts: {
totalVAs: vasFormatted.length, totalProjects: totalProjects,
totalProjects: projectsFormatted.length totalVAs: allVAsAcrossProjects.size,
activeVAs: totalActiveVAs.size,
projectsWithActiveVAs: projectsWithActiveVAs
} }
}); });
} catch (error) { } catch (error) {
console.error("Client dashboard error:", error); console.error("Client dashboard projects error:", error);
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: "Internal Server Error", message: "Internal Server Error",

View File

@ -0,0 +1,44 @@
import { ClientModel } from "../../../models/vaModel.js";
import jwt from "jsonwebtoken";
async function client_logout_callback(req, res) {
try {
const accessToken = req.headers.token;
if (!accessToken) {
return res.status(401).json({ success: false, message: "Missing token" });
}
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
if (!decoded || !decoded.email) {
return res.status(401).json({ success: false, message: "Invalid token" });
}
const clientEmail = decoded.email;
const clientUser = await ClientModel.findOne({ "userDetails.email": clientEmail });
if (!clientUser) {
return res.status(404).json({ success: false, message: "Client not found" });
}
// Find user by email
const user = await ClientModel.findOne({ "userDetails.email": clientEmail });
if (!user) {
return res.status(404).json({ success: false, message: "Invalid credentials" });
}
await ClientModel.updateOne({ "userDetails.email": clientEmail }, {
$set: { accessToken: null, refreshToken: null }
})
// Return accessToken and user info
return res.status(200).json({
success: true,
message: "Log Out Successfull",
});
} catch (error) {
console.error("client logout error:", error);
return res.status(500).json({ success: false, message: "Internal Server Error" });
}
}
export { client_logout_callback };

View File

@ -1,9 +1,23 @@
import VaClientRepository from "../../repository/vaClient.repository.js"; import VaClientRepository from "../../repository/vaClient.repository.js";
import VaClientService from "../../services/vaClient.service.js"; import VaClientService from "../../services/vaClient.service.js";
import logger from "../../config/logger.js"; import logger from "../../config/logger.js";
import jwt from "jsonwebtoken";
import PasswordResetService from "../../services/passwordReset.service.js";
import PasswordResetRepository from "../../repository/passwordReset.repository.js";
const vaClientService = new VaClientService(new VaClientRepository()); const vaClientService = new VaClientService(new VaClientRepository());
const passwordResetService = new PasswordResetService(
new PasswordResetRepository(),
// new EmailService({
// host: process.env.SMTP_HOST,
// port: process.env.SMTP_PORT,
// user: process.env.SMTP_USER,
// pass: process.env.SMTP_PASS,
// from: process.env.EMAIL_FROM,
// })
);
async function va_register_client(req, res) { async function va_register_client(req, res) {
logger.info("ClientController - Register Client Hit ..."); logger.info("ClientController - Register Client Hit ...");
@ -11,16 +25,19 @@ async function va_register_client(req, res) {
try { try {
const payload = req.body || {}; const payload = req.body || {};
const { userDetails } = payload; const { userDetails } = payload;
if (!userDetails || !userDetails.email || !userDetails.name) { if (!userDetails || !userDetails.email || !userDetails.firstName || !userDetails.lastName || !userDetails.password || !userDetails.name) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "Missing required fields: name and email are required" message: "Missing required fields: firstName, lastName, email, password, and company name are required"
}); });
} }
const created = await vaClientService.registerClient(payload); const created = await vaClientService.registerClient(payload);
logger.info(`ClientController - Client registered successfully: ${userDetails.email}`); logger.info(`ClientController - Client registered successfully: ${userDetails.email}`);
await passwordResetService.requestPasswordReset(userDetails.email, 'client');
logger.info(`Client password reset requested for: ${userDetails.email}`);
return res.status(201).json({ return res.status(201).json({
success: true, success: true,
data: created, data: created,
@ -51,9 +68,9 @@ async function va_register_client(req, res) {
} }
// Additional controller methods can be added here // Additional controller methods can be added here
async function get_client_by_email(req, res) { async function get_client_profile(req, res) {
try { try {
const { email } = req.params; const email = req.body.clientEmail;
const client = await vaClientService.getClientByEmail(email); const client = await vaClientService.getClientByEmail(email);
if (!client) { if (!client) {
@ -76,7 +93,233 @@ async function get_client_by_email(req, res) {
} }
} }
async function delete_client(req, res) {
logger.info("ClientController - Delete Client Hit ...");
try {
const { clientId } = req.params;
if (!clientId) {
return res.status(400).json({
success: false,
message: "Client ID is required"
});
}
const deletedClient = await vaClientService.deleteClient(clientId);
if (!deletedClient) {
return res.status(404).json({
success: false,
message: "Client not found"
});
}
logger.info(`ClientController - Client deleted successfully: ${clientId}`);
return res.status(200).json({
success: true,
message: "Client deleted successfully",
data: {
clientId: clientId,
clientName: deletedClient.userDetails?.name || 'Unknown Client',
deletedAt: new Date().toISOString()
}
});
} catch (error) {
logger.error(`ClientController - Delete error: ${error.message}`);
if (error.message === "Client not found") {
return res.status(404).json({
success: false,
message: error.message
});
}
return res.status(500).json({
success: false,
message: "Internal Server Error"
});
}
}
async function update_client(req, res) {
logger.info("ClientController - Update Client Hit ...");
try {
const { clientId } = req.params;
const payload = req.body || {};
const { userDetails } = payload;
if (!clientId) {
return res.status(400).json({
success: false,
message: "Client ID is required"
});
}
if (!userDetails) {
return res.status(400).json({
success: false,
message: "Missing userDetails in request body"
});
}
const updatedClient = await vaClientService.updateClient(clientId, payload);
logger.info(`ClientController - Client updated successfully: ${clientId}`);
return res.status(200).json({
success: true,
message: "Client updated successfully",
data: updatedClient
});
} catch (error) {
logger.error(`ClientController - Update error: ${error.message}`);
if (error.message === "Client not found") {
return res.status(404).json({
success: false,
message: error.message
});
}
if (error.message.includes("Email already exists")) {
return res.status(409).json({
success: false,
message: error.message
});
}
return res.status(500).json({
success: false,
message: error.message
});
}
}
async function get_client_dashboard_profile(req, res) {
try {
const accessToken = req.headers.token;
if (!accessToken) {
return res.status(401).json({ success: false, message: "Missing token" });
}
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
if (!decoded || !decoded.email) {
return res.status(401).json({ success: false, message: "Invalid token" });
}
const clientEmail = decoded.email;
// Use the new method that returns only necessary fields
const client = await vaClientService.getClientProfileByEmail(clientEmail);
if (!client) {
return res.status(404).json({
success: false,
message: "Client not found"
});
}
// Format the response to only include userDetails
const profileData = {
name: client.userDetails?.name || '',
email: client.userDetails?.email || '',
address: client.userDetails?.address || '',
city: client.userDetails?.city || '',
state: client.userDetails?.state || '',
zip: client.userDetails?.zip || '',
position: client.userDetails?.position || ''
};
return res.status(200).json({
success: true,
data: profileData
});
} catch (error) {
logger.error(`ClientController - Get dashboard profile error: ${error.message}`);
return res.status(500).json({
success: false,
message: "Internal Server Error"
});
}
}
async function update_client_password(req, res) {
logger.info("ClientController - Update Client Password Hit ...");
console.log("Update Client Password - Request Body:", req.body);
try {
const accessToken = req.headers.token;
if (!accessToken) {
return res.status(401).json({ success: false, message: "Missing token" });
}
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
if (!decoded || !decoded.email) {
return res.status(401).json({ success: false, message: "Invalid token" });
}
const clientEmail = decoded.email;
const payload = req.body || {};
const { userDetails } = payload;
// Get client by email to get the ID
const client = await vaClientService.getClientByEmail(clientEmail);
if (!client) {
return res.status(404).json({
success: false,
message: "Client not found"
});
}
const clientId = client._id;
// Call service to update password
const result = await vaClientService.updateClientPassword(clientId, userDetails);
logger.info(`ClientController - Client password updated successfully: ${clientEmail}`);
return res.status(200).json({
success: true,
message: "Password updated successfully",
data: result
});
} catch (error) {
logger.error(`ClientController - Update password error: ${error.message}`);
if (error.message === "Client not found") {
return res.status(404).json({
success: false,
message: error.message
});
}
if (error.message.includes("Current password is incorrect") ||
error.message.includes("New passwords do not match") ||
error.message.includes("All password fields are required")) {
return res.status(400).json({
success: false,
message: error.message
});
}
return res.status(500).json({
success: false,
message: "Internal Server Error"
});
}
}
export { export {
va_register_client, va_register_client,
get_client_by_email get_client_profile,
delete_client,
update_client,
get_client_dashboard_profile,
update_client_password
}; };

View File

@ -1,6 +1,6 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { ClientModel } from "../../models/vaModel.js"; import { ClientModel, VAModel } from "../../models/vaModel.js";
// Login VA // Login VA
async function va_login_client(req, res) { async function va_login_client(req, res) {
@ -13,8 +13,17 @@ async function va_login_client(req, res) {
return res.status(400).json({ success: false, message: "Please provide email and password" }); return res.status(400).json({ success: false, message: "Please provide email and password" });
} }
let isVa = false;
// Find user by email // Find user by email
const user = await ClientModel.findOne({ "userDetails.email": userDetails.email }); let user = await ClientModel.findOne({ "userDetails.email": userDetails.email });
const vaUser = await VAModel.findOne({ "userDetails.email": userDetails.email });
if (vaUser) {
isVa = true
user = vaUser
}
if (!user) { if (!user) {
return res.status(404).json({ success: false, message: "Invalid credentials" }); return res.status(404).json({ success: false, message: "Invalid credentials" });
} }
@ -49,6 +58,7 @@ async function va_login_client(req, res) {
id: user._id, id: user._id,
email: user.userDetails.email, email: user.userDetails.email,
name: user.userDetails.firstName, name: user.userDetails.firstName,
userType: isVa ? "VA" : "CLIENT",
}, },
process.env.JWT_SECRET_REFRESH_TOKEN || "DocTrackerSecretKeyRefreshToken", process.env.JWT_SECRET_REFRESH_TOKEN || "DocTrackerSecretKeyRefreshToken",
{ expiresIn: "30d" } { expiresIn: "30d" }
@ -69,6 +79,7 @@ async function va_login_client(req, res) {
name: user.userDetails.firstName, name: user.userDetails.firstName,
email: user.userDetails.email, email: user.userDetails.email,
}, },
userType: isVa ? "VA" : "CLIENT",
}); });
} catch (error) { } catch (error) {
console.error("va_client_login_register error:", error); console.error("va_client_login_register error:", error);

View File

@ -1,17 +1,17 @@
import PasswordResetRepository from "../repository/passwordReset.repository.js"; import PasswordResetRepository from "../repository/passwordReset.repository.js";
import PasswordResetService from "../services/passwordReset.service.js"; import PasswordResetService from "../services/passwordReset.service.js";
import EmailService from "../services/email.service.js"; // import EmailService from "../services/email.service.js";
import logger from "../config/logger.js"; import logger from "../config/logger.js";
const passwordResetService = new PasswordResetService( const passwordResetService = new PasswordResetService(
new PasswordResetRepository(), new PasswordResetRepository(),
new EmailService({ // new EmailService({
host: process.env.SMTP_HOST, // host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT, // port: process.env.SMTP_PORT,
user: process.env.SMTP_USER, // user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS, // pass: process.env.SMTP_PASS,
from: process.env.EMAIL_FROM, // from: process.env.EMAIL_FROM,
}) // })
); );
// VA: Request password reset // VA: Request password reset
@ -55,26 +55,50 @@ async function render_reset_form(req, res) {
try { try {
const { token, userType } = req.query; const { token, userType } = req.query;
// ❌ Missing params
if (!token || !userType) { if (!token || !userType) {
return res.render('auth/reset-password-error', { error: 'Reset token or user type is missing', userType: userType || 'va' }); return res.render('auth/reset-password-error', {
title: "Reset Password",
layout: 'layouts/auth',
error: 'Reset token or user type is missing',
userType: userType || 'va'
});
} }
// Validate token // ✅ Validate token
const validation = await passwordResetService.validateResetToken(token, userType); const validation = await passwordResetService.validateResetToken(token, userType);
if (!validation.valid) { if (!validation.valid) {
return res.render('auth/reset-password-error', { error: validation.message, userType: userType || 'va' }); return res.render('auth/reset-password-error', {
title: "Reset Password",
layout: 'layouts/auth',
error: validation.message,
userType: userType || 'va'
});
} }
// Render reset form with token // ✅ Render reset password form
res.render('auth/reset-password-form', { token, userType, error: null }); return res.render('auth/reset-password-form', {
title: "Reset Password",
layout: 'layouts/auth',
token,
userType,
error: null
});
} catch (error) { } catch (error) {
logger.error(`PasswordResetController - Render form error: ${error.message}`); logger.error(`PasswordResetController - Render form error: ${error.message}`);
res.render('auth/reset-password-error', { error: 'Failed to load reset form' });
return res.render('auth/reset-password-error', {
title: "Reset Password",
layout: 'layouts/auth',
error: 'Failed to load reset form',
userType: 'va'
});
} }
} }
// Process password reset form submission (shared for both VA and Client) // Process password reset form submission (shared for both VA and Client)
async function process_reset(req, res) { async function process_reset(req, res) {
try { try {

View File

@ -0,0 +1,97 @@
import { ProjectModel, VAModel, ClientModel } from "../../models/vaModel.js";
import { Types } from "mongoose";
/**
* Delete project and all related data (without transaction)
*/
async function delete_project_sf(req, res) {
try {
const { projectId } = req.params;
// Validate projectId
if (!projectId) {
return res.status(400).json({
success: false,
message: "Project ID is required"
});
}
let projectObjectId;
try {
projectObjectId = new Types.ObjectId(projectId);
} catch (error) {
return res.status(400).json({
success: false,
message: "Invalid Project ID format"
});
}
// Check if project exists
const existingProject = await ProjectModel.findById(projectObjectId);
if (!existingProject) {
return res.status(404).json({
success: false,
message: "Project not found"
});
}
const clientId = existingProject.clientId;
const vaAssigned = existingProject.vaAssigned || [];
const projectName = existingProject.name;
console.log(`Deleting project: ${projectName} (${projectId})`);
const deletionSummary = {};
// 1. Remove project from Client's projectIds array
if (clientId) {
await ClientModel.findByIdAndUpdate(
clientId,
{ $pull: { projectIds: projectObjectId } }
);
deletionSummary.clientUpdated = true;
console.log(`Removed project from client`);
}
// 2. Remove project from all assigned VAs' projectIds arrays
if (vaAssigned.length > 0) {
const vaResult = await VAModel.updateMany(
{ _id: { $in: vaAssigned.vaId } },
{ $pull: { projectIds: projectObjectId } }
);
deletionSummary.vasUpdated = vaResult.modifiedCount;
console.log(`Removed project from ${deletionSummary.vasUpdated} VAs`);
} else {
deletionSummary.vasUpdated = 0;
}
// 6. Finally delete the project
await ProjectModel.findByIdAndDelete(projectObjectId);
console.log(`✅ Successfully deleted project: ${projectName} (${projectId})`);
res.status(200).json({
success: true,
message: 'Project and all related data deleted successfully',
data: {
projectId: projectId,
projectName: projectName,
clientId: clientId,
deletedAt: new Date().toISOString()
}
});
} catch (error) {
console.error("delete_project_sf error:", error);
return res.status(500).json({
success: false,
message: "Failed to delete project",
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: error.message
});
}
}
export { delete_project_sf };

View File

@ -4,8 +4,8 @@ import { updateEntityRelationships } from "../../helper/updateEntityRelationship
// Register a new VA // Register a new VA
async function project_add_sf(req, res) { async function project_add_sf(req, res) {
try { try {
const payload = req.body|| {}; const payload = req.body || {};
const { name, hourlyRate, clientId, vaAssigned, dedicatedHours } = payload; const { name, hourlyRate, clientId, vaAssigned, dedicatedHours, vaWorkingScheduleStart, vaWorkingScheduleEnd, isFlexibleAllowed, vaWorkingDelayAllowedStart, vaWorkingDelayAllowedEnd } = payload;
// Validate required fields // Validate required fields
if (!name) { if (!name) {
@ -34,41 +34,55 @@ async function project_add_sf(req, res) {
} }
// Validate and convert vaAssigned to array of ObjectIds // Validate and convert vaAssigned to array of ObjectIds
let vaAssignedArray = []; // let vaAssignedArray = [];
if (vaAssigned) { // if (vaAssigned) {
if (Array.isArray(vaAssigned)) { // if (Array.isArray(vaAssigned)) {
try { // try {
vaAssignedArray = vaAssigned.map(vaId => new Types.ObjectId(vaId)); // vaAssignedArray = vaAssigned.map(vaId => {
} catch (error) { // if(typeof vaId === 'object' && "isFlexibleAllowed" in vaId){
return res.status(400).json({ // // Handle Error form SF Side for now - That vaId is coming as object
success: false, // return new Types.ObjectId(vaId.vaId)
message: "Invalid VA ID format in vaAssigned array" // } else {
}); // return new Types.ObjectId(vaId)
} // }
} else if (typeof vaAssigned === 'string') { // });
// Handle single VA ID as string // } catch (error) {
try { // return res.status(400).json({
vaAssignedArray = [new Types.ObjectId(vaAssigned)]; // success: false,
} catch (error) { // message: "Invalid VA ID format in vaAssigned array"
return res.status(400).json({ // });
success: false, // }
message: "Invalid VA ID format" // } else if (typeof vaAssigned === 'string') {
}); // // Handle single VA ID as string
} // try {
} // vaAssignedArray = [new Types.ObjectId(vaAssigned)];
} // } catch (error) {
// return res.status(400).json({
// success: false,
// message: "Invalid VA ID format"
// });
// }
// }
// }
// sanitize/construct allowed fields // sanitize/construct allowed fields
const toCreate = { const toCreate = {
name: name.trim(), name: name.trim(),
hourlyRate: hourlyRate || '0', hourlyRate: hourlyRate || '0',
clientId: clientObjectId, clientId: clientObjectId,
vaAssigned: vaAssignedArray, vaAssigned: vaAssigned,
dedicatedHours: dedicatedHours ? parseFloat(dedicatedHours) : 0, dedicatedHours: dedicatedHours || ["0", "0", "0", "0", "0", "0", "0"],
vaWorkingScheduleStart: vaWorkingScheduleStart || null,
vaWorkingScheduleEnd: vaWorkingScheduleEnd || null,
isFlexibleAllowed: isFlexibleAllowed || true,
vaWorkingDelayAllowedStart: vaWorkingDelayAllowedStart || null,
vaWorkingDelayAllowedEnd: vaWorkingDelayAllowedEnd || null
}; };
const created = await ProjectModel.create(toCreate); const created = await ProjectModel.create(toCreate);
const vaAssignedArray = vaAssigned ? vaAssigned.map(vaDtl => vaDtl.vaId) : [];
const relationshipUpdate = await updateEntityRelationships({ const relationshipUpdate = await updateEntityRelationships({
project: { project: {
projectId: created._id, projectId: created._id,
@ -81,36 +95,8 @@ async function project_add_sf(req, res) {
console.warn('Project created but relationship update failed:', relationshipUpdate.message); console.warn('Project created but relationship update failed:', relationshipUpdate.message);
} }
// Send data to Salesforce API after successful registration res.status(201).json({ success: true, data: created, relationships: relationshipUpdate });
try {
// const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/`;
// const response = await fetch(salesforceUrl, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(created)
// });
// 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);
// res.status(201).json({ success: true, data: created, SyncSalesforce: result });
res.status(201).json({ success: true, data: created, relationships: relationshipUpdate });
} catch (error) {
console.error("❌ Failed to sync with Salesforce:", error.message);
// Create a custom error to distinguish Salesforce errors
const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
salesforceError.isSalesforceError = true;
throw salesforceError;
}
} catch (error) { } catch (error) {
console.error("project_add_sf error:", error); console.error("project_add_sf error:", error);
return res.status(500).json({ success: false, message: "Internal Server Error" }); return res.status(500).json({ success: false, message: "Internal Server Error" });

View File

@ -16,7 +16,12 @@ async function update_project_sf(req, res) {
vaAssigned, vaAssigned,
dedicatedHours, dedicatedHours,
status, status,
totalLoggedHours totalLoggedHours,
vaWorkingScheduleStart,
vaWorkingScheduleEnd,
isFlexibleAllowed,
vaWorkingDelayAllowedStart,
vaWorkingDelayAllowedEnd
} = payload; } = payload;
// Validate projectId // Validate projectId
@ -69,7 +74,7 @@ async function update_project_sf(req, res) {
// Update dedicated hours if provided // Update dedicated hours if provided
if (dedicatedHours !== undefined) { if (dedicatedHours !== undefined) {
updateData.dedicatedHours = parseFloat(dedicatedHours) || 0; updateData.dedicatedHours = dedicatedHours || ["0", "0", "0", "0", "0", "0", "0"];
} }
// Update total logged hours if provided // Update total logged hours if provided
@ -88,15 +93,47 @@ async function update_project_sf(req, res) {
updateData.status = status; updateData.status = status;
} }
// Update VA working schedule if provided
if (vaWorkingScheduleStart !== undefined) {
updateData.vaWorkingScheduleStart = vaWorkingScheduleStart || null;
}
if (vaWorkingScheduleEnd !== undefined) {
updateData.vaWorkingScheduleEnd = vaWorkingScheduleEnd || null;
}
if (isFlexibleAllowed !== undefined) {
updateData.isFlexibleAllowed = isFlexibleAllowed;
}
if (vaWorkingDelayAllowedStart !== undefined) {
updateData.vaWorkingDelayAllowedStart = vaWorkingDelayAllowedStart || null;
}
if (vaWorkingDelayAllowedEnd !== undefined) {
updateData.vaWorkingDelayAllowedEnd = vaWorkingDelayAllowedEnd || null;
}
if (vaAssigned !== undefined && vaAssigned !== null && Array.isArray(vaAssigned)) {
updateData.vaAssigned = vaAssigned;
}
// Handle VA assignments - this requires relationship updates // Handle VA assignments - this requires relationship updates
if (vaAssigned !== undefined) { if (vaAssigned !== undefined) {
let newVaAssignedArray = []; let newVaAssignedArray = updateData.vaAssigned.map(vaDtl => vaDtl.vaId);
// Parse and validate VA IDs // Parse and validate VA IDs
if (vaAssigned && vaAssigned.length > 0) { if (vaAssigned && vaAssigned.length > 0) {
if (Array.isArray(vaAssigned)) { if (Array.isArray(vaAssigned)) {
try { try {
newVaAssignedArray = vaAssigned.map(vaId => new Types.ObjectId(vaId)); newVaAssignedArray = vaAssigned.map(vaId => {
if (typeof vaId === 'object' && "isFlexibleAllowed" in vaId) {
// Handle Error form SF Side for now - That vaId is coming as object
return new Types.ObjectId(vaId.vaId)
} else {
return new Types.ObjectId(vaId)
}
});
} catch (error) { } catch (error) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@ -115,14 +152,14 @@ async function update_project_sf(req, res) {
} }
} }
updateData.vaAssigned = newVaAssignedArray; // updateData.vaAssigned = newVaAssignedArray;
// Calculate VAs to add and remove for relationship updates // Calculate VAs to add and remove for relationship updates
const currentVaIds = existingProject.vaAssigned.map(va => va.toString()); const currentVaIds = existingProject.vaAssigned.map(va => va.vaId.toString());
const newVaIds = newVaAssignedArray.map(va => va.toString()); const newVaIds = newVaAssignedArray.map(va => va.toString());
const vaToAdd = newVaAssignedArray.filter(va => !currentVaIds.includes(va.toString())); const vaToAdd = newVaAssignedArray.filter(va => !currentVaIds.includes(va.toString()));
const vaToRemove = existingProject.vaAssigned.filter(va => !newVaIds.includes(va.toString())); const vaToRemove = existingProject.vaAssigned.filter(va => !newVaIds.includes(va.vaId.toString()));
// Update relationships for added VAs // Update relationships for added VAs
if (vaToAdd.length > 0) { if (vaToAdd.length > 0) {
@ -161,7 +198,7 @@ async function update_project_sf(req, res) {
updateData, updateData,
{ new: true, runValidators: true } { new: true, runValidators: true }
).populate('clientId', 'userDetails.name userDetails.email') ).populate('clientId', 'userDetails.name userDetails.email')
.populate('vaAssigned', 'userDetails.firstName userDetails.lastName userDetails.email'); .populate('vaAssigned', 'userDetails.firstName userDetails.lastName userDetails.email');
// If VA assignments were changed but relationship update failed, log warning // If VA assignments were changed but relationship update failed, log warning
if (vaAssigned !== undefined) { if (vaAssigned !== undefined) {

View File

@ -1,101 +1,102 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { VAModel, LoggedModel, WorkTrackModel } from "../../../models/vaModel.js"; import { VAModel, LoggedModel, WorkTrackModel } from "../../../models/vaModel.js";
import authFetch from "../../../utils/authFetch.js";
// function to close open work tracks and create logout // function to close open work tracks and create logout
async function closePreviousSession(vaId, lastLog) { // async function closePreviousSession(vaId, lastLog) {
try { // try {
const currentTime = new Date(); // const currentTime = new Date();
// Find all open work tracks for this VA and project (where to is not set or totalTime is 0) // // Find all open work tracks for this VA and project (where to is not set or totalTime is 0)
const openWorkTracks = await WorkTrackModel.find({ // const openWorkTracks = await WorkTrackModel.find({
vaId, // vaId,
logId: lastLog._id, // logId: lastLog._id,
$or: [ // $or: [
{ to: { $exists: false } }, // { to: { $exists: false } },
{ totalTime: 0 }, // { totalTime: 0 },
{ to: null } // { to: null }
] // ]
}); // });
// console.log(`Found ${openWorkTracks.length} open work tracks to close`); // console.log(`Found ${openWorkTracks.length} open work tracks to close`);
// Close all open work tracks // // Close all open work tracks
for (const workTrack of openWorkTracks) { // for (const workTrack of openWorkTracks) {
const fromTime = new Date(workTrack.from); // const fromTime = new Date(workTrack.from);
const totalTimeMs = currentTime - fromTime; // const totalTimeMs = currentTime - fromTime;
// Update the work track with end time and total time // // Update the work track with end time and total time
workTrack.to = currentTime; // workTrack.to = currentTime;
workTrack.totalTime = totalTimeMs; // workTrack.totalTime = totalTimeMs;
await workTrack.save(); // await workTrack.save();
// console.log(`Closed work track ${workTrack._id} with duration: ${totalTimeMs}ms`); // // console.log(`Closed work track ${workTrack._id} with duration: ${totalTimeMs}ms`);
} // }
// Create a logout log entry // // Create a logout log entry
const logoutLog = await LoggedModel.create({ // const logoutLog = await LoggedModel.create({
loggedType: "LOGOUT", // loggedType: "LOGOUT",
vaId, // vaId,
vaEmail: lastLog.vaEmail, // Use email from previous log // vaEmail: lastLog.vaEmail, // Use email from previous log
projectId: lastLog.projectId, // projectId: lastLog.projectId,
time: currentTime // Use same timestamp for consistent logging // time: currentTime // Use same timestamp for consistent logging
}); // });
// console.log(`Created logout log: ${logoutLog._id}`); // // console.log(`Created logout log: ${logoutLog._id}`);
// try { // try {
// const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`; // const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
// const sfDataSync = { // const sfDataSync = {
// "_id": logoutLog._id, // "_id": logoutLog._id,
// "loggedType": "LOGOUT", // "loggedType": "LOGOUT",
// "vaId": vaId, // "vaId": vaId,
// "vaEmail": lastLog.vaEmail, // "vaEmail": lastLog.vaEmail,
// "projectId": lastLog.projectId, // "projectId": lastLog.projectId,
// "time": logoutLog.time, // "time": logoutLog.time,
// } // }
// const response = await fetch(salesforceUrl, { // const response = await authFetch(salesforceUrl, {
// method: 'POST', // method: 'POST',
// headers: { // headers: {
// 'Content-Type': 'application/json', // 'Content-Type': 'application/json',
// // Add any required headers for Salesforce authentication // // Add any required headers for Salesforce authentication
// // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN // // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
// }, // },
// body: JSON.stringify(sfDataSync) // body: JSON.stringify(sfDataSync)
// }); // });
// if (!response.ok) { // if (!response.ok) {
// const errorText = await response.text(); // const errorText = await response.text();
// throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`); // throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
// } // }
// const result = await response.json(); // const result = await response.json();
// // console.log("✅ Successfully synced VA data to Salesforce:", result); // // console.log("✅ Successfully synced VA data to Salesforce:", result);
// return { // return {
// closedWorkTracks: openWorkTracks.length, // closedWorkTracks: openWorkTracks.length,
// logoutLogId: logoutLog._id, // logoutLogId: logoutLog._id,
// SyncSalesforce: result // SyncSalesforce: result
// }; // };
// } catch (error) { // } catch (error) {
// console.error("Failed to sync with Salesforce:", error.message); // console.error("Failed to sync with Salesforce:", error.message);
// console.log(error) // console.log(error)
// // Create a custom error to distinguish Salesforce errors // // Create a custom error to distinguish Salesforce errors
// const salesforceError = new Error(`Salesforce sync failed: ${error.message}`); // const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
// salesforceError.isSalesforceError = true; // salesforceError.isSalesforceError = true;
// throw salesforceError; // throw salesforceError;
// } // }
return { // // return {
closedWorkTracks: openWorkTracks.length, // // closedWorkTracks: openWorkTracks.length,
logoutLogId: logoutLog._id, // // logoutLogId: logoutLog._id,
}; // // };
} catch (error) { // } catch (error) {
console.error("Error closing previous session:", error); // console.error("Error closing previous session:", error);
throw error; // throw error;
} // }
} // }
// Login VA // Login VA
async function va_login_callback(req, res) { async function va_login_callback(req, res) {
@ -131,17 +132,17 @@ async function va_login_callback(req, res) {
// also on main login when user try to login into app then logout project and workModel track then allow login on same timestamp // also on main login when user try to login into app then logout project and workModel track then allow login on same timestamp
// Check if VA has any active sessions (LOGIN without LOGOUT) // Check if VA has any active sessions (LOGIN without LOGOUT)
const activeSessions = await LoggedModel.find({ // const activeSessions = await LoggedModel.find({
vaId: user._id // vaId: user._id
}).sort({ time: -1 }); // }).sort({ time: -1 });
let closeResult = []; // let closeResult = [];
if (activeSessions.length > 0) { // if (activeSessions.length > 0) {
// Get the latest login to check if there's no corresponding logout // // Get the latest login to check if there's no corresponding logout
const latestLog = activeSessions[0]; // const latestLog = activeSessions[0];
closeResult = await closePreviousSession(user._id, latestLog); // closeResult = await closePreviousSession(user._id, latestLog);
} // }
// Generate JWT accessToken with 24h expiry // Generate JWT accessToken with 24h expiry
const accessToken = jwt.sign( const accessToken = jwt.sign(
@ -171,23 +172,23 @@ async function va_login_callback(req, res) {
let responseData = []; let responseData = [];
if (closeResult) { // if (closeResult) {
responseData = { // responseData = {
success: true, // success: true,
message: "Login Successfull", // message: "Login Successfull",
accessToken, // accessToken,
refreshToken, // refreshToken,
user: { // user: {
id: user._id, // id: user._id,
name: user.userDetails.firstName, // name: user.userDetails.firstName,
email: user.userDetails.email, // email: user.userDetails.email,
}, // },
previousSession: { // previousSession: {
closedWorkTracks: closeResult.closedWorkTracks, // closedWorkTracks: closeResult.closedWorkTracks,
logoutLogId: closeResult.logoutLogId // logoutLogId: closeResult.logoutLogId
} // }
} // }
} else { // } else {
responseData = { responseData = {
success: true, success: true,
message: "Login Successfull", message: "Login Successfull",
@ -199,7 +200,7 @@ async function va_login_callback(req, res) {
email: user.userDetails.email, email: user.userDetails.email,
} }
} }
} // }
// Return accessToken and user info // Return accessToken and user info
return res.status(200).json(responseData); return res.status(200).json(responseData);

View File

@ -1,5 +1,6 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { VAModel, LeavesModel } from "../../../models/vaModel.js"; import { VAModel, LeavesModel } from "../../../models/vaModel.js";
import authFetch from "../../../utils/authFetch.js";
// Apply for leave // Apply for leave
async function va_leave_apply_function(req, res) { async function va_leave_apply_function(req, res) {
@ -131,6 +132,7 @@ async function va_leave_apply_function(req, res) {
}); });
} }
const processedComment = processHTMLComment(comment);
// Create leave request // Create leave request
const leaveRequest = new LeavesModel({ const leaveRequest = new LeavesModel({
vaId, vaId,
@ -138,72 +140,74 @@ async function va_leave_apply_function(req, res) {
dateTo: endDate, dateTo: endDate,
days: totalDays.toString(), days: totalDays.toString(),
leaveType, leaveType,
comment, comment: processedComment.processedHTML,
attachments: processedComment.extractedImages,
status: "PENDING" status: "PENDING"
}); });
await leaveRequest.save(); await leaveRequest.save();
// try { try {
// const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/timesheet-adjustment`; const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/leave-request`;
// const sfData = { const sfData = {
// "id": adjustmentRequest._id, "leaveId": leaveRequest._id.toString(),
// "va": { // "va": {
// "id": vaId // "id": vaId
// }, // },
// "project": { "va": vaId.toString(),
// "id": projectId "dateFrom": startDate.toISOString().split('T')[0],
// }, "dateTo": endDate.toISOString().split('T')[0],
// "date": adjustmentDate.toISOString().split('T')[0], "totalDays": totalDays,
// "loginTime": loginDateTime.toISOString(), "leaveType": leaveType,
// "logoutTime": logoutDateTime.toISOString(), "comment": processedComment.plainText !== "" ? processedComment.plainText : "-", // Send plain text to Salesforce
// "comment": comment, "attachments": processedComment.extractedImages, // Send extracted images info
// "status": "PENDING", "status": "PENDING",
// "rejectionReason": "", "rejectionReason": ""
// "adjustedHours": formatTime(adjustedSeconds) }
// }
// const response = await fetch(salesforceUrl, { const response = await authFetch(salesforceUrl, {
// method: 'POST', method: 'POST',
// headers: { headers: {
// 'Content-Type': 'application/json', 'Content-Type': 'application/json',
// // Add any required headers for Salesforce authentication // Add any required headers for Salesforce authentication
// // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
// }, },
// body: JSON.stringify({"data": sfData}) body: JSON.stringify({ "data": sfData })
// }); });
// if (!response.ok) { if (!response.ok) {
// const errorText = await response.text(); const errorText = await response.text();
// throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`); await LeavesModel.findByIdAndDelete(leaveRequest._id)
// } throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
}
// const result = await response.json(); // const result = await response.json();
// // console.log("✅ Successfully synced VA data to Salesforce:", result); // console.log("✅ Successfully synced VA data to Salesforce:", result);
// return res.status(201).json({ // return res.status(201).json({
// success: true, // success: true,
// message: "Adjustment request submitted successfully", // message: "Leave request submitted successfully",
// data: { // data: {
// adjustmentId: adjustmentRequest._id, // leaveId: leaveRequest._id,
// date: adjustmentDate.toISOString().split('T')[0], // dateFrom: startDate.toISOString().split('T')[0],
// loginTime: loginDateTime.toISOString(), // dateTo: endDate.toISOString().split('T')[0],
// logoutTime: logoutDateTime.toISOString(), // totalDays: totalDays,
// adjustedHours: formatTime(adjustedSeconds), // leaveType,
// comment: comment, // comment: processedComment.processedHTML,
// status: "PENDING" // // attachments: processedComment.extractedImages,
// }, // status: "PENDING"
// SyncSalesforce: result // },
// }); // // SyncSalesforce: result
// } catch (error) { // });
// console.error("Failed to sync with Salesforce:", error.message); } catch (error) {
// console.log(error) console.error("Failed to sync with Salesforce:", error.message);
console.log(error)
// // Create a custom error to distinguish Salesforce errors // Create a custom error to distinguish Salesforce errors
// const salesforceError = new Error(`Salesforce sync failed: ${error.message}`); const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
// salesforceError.isSalesforceError = true; salesforceError.isSalesforceError = true;
// throw salesforceError; throw salesforceError;
// } }
return res.status(201).json({ return res.status(201).json({
success: true, success: true,
@ -238,4 +242,116 @@ async function va_leave_apply_function(req, res) {
} }
} }
function processHTMLComment(htmlComment) {
try {
let processedHTML = htmlComment;
const extractedImages = [];
let plainText = '';
// Extract plain text from HTML for Salesforce
plainText = extractPlainTextFromHTML(htmlComment);
// Process base64 images in HTML
const imageProcessingResult = extractAndProcessBase64Images(htmlComment);
processedHTML = imageProcessingResult.processedHTML;
extractedImages.push(...imageProcessingResult.extractedImages);
return {
processedHTML,
plainText,
extractedImages
};
} catch (error) {
console.error("Error processing HTML comment:", error);
// Return original content if processing fails
return {
processedHTML: htmlComment,
plainText: extractPlainTextFromHTML(htmlComment),
extractedImages: []
};
}
}
// Helper function to extract plain text from HTML
function extractPlainTextFromHTML(html) {
try {
// Remove HTML tags and decode HTML entities
let plainText = html
.replace(/<[^>]*>/g, ' ') // Replace tags with spaces
.replace(/\s+/g, ' ') // Collapse multiple spaces
.trim();
// Decode common HTML entities
plainText = plainText
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, '/');
return plainText;
} catch (error) {
console.error("Error extracting plain text from HTML:", error);
return html.replace(/<[^>]*>/g, ' ').trim();
}
}
// Helper function to extract and process base64 images from HTML
function extractAndProcessBase64Images(html) {
const extractedImages = [];
let processedHTML = html;
try {
// Regex to find base64 images in HTML (various formats)
const base64Regex = /<img[^>]+src="data:image\/([a-zA-Z0-9+]+);base64,([^"]+)"[^>]*>/gi;
let match;
let imageIndex = 0;
while ((match = base64Regex.exec(html)) !== null) {
const fullMatch = match[0];
const imageType = match[1]; // png, jpeg, gif, etc.
const base64Data = match[2];
// 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
// Store image information
extractedImages.push({
filename: filename,
mimeType: `image/${imageType}`,
base64Data: base64Data,
size: size,
originalHTML: fullMatch, // Store original img tag for reference
index: imageIndex
});
// Replace base64 with a reference in HTML
const replacementHTML = `<img src="attachment://${filename}" alt="Leave evidence ${imageIndex + 1}" data-filename="${filename}" data-image-index="${imageIndex}" class="leave-image">`;
processedHTML = processedHTML.replace(fullMatch, replacementHTML);
imageIndex++;
}
return {
processedHTML,
extractedImages
};
} catch (error) {
console.error("Error processing base64 images:", error);
return {
processedHTML: html,
extractedImages: []
};
}
}
export { va_leave_apply_function }; export { va_leave_apply_function };

View File

@ -26,128 +26,136 @@ function formatHoursToReadable(hours) {
} }
} }
// Helper function to check daily hours and create logout log if needed // // Helper function to check daily hours and create logout log if needed
async function checkDailyHoursAndLogout(vaId, vaEmail, projectId, project, currentSessionId = null, additionalTimeMs = 0) { // async function checkDailyHoursAndLogout(vaId, vaEmail, projectId, project, currentSessionId = null, additionalTimeMs = 0) {
try { // try {
const today = new Date(); // const today = new Date();
const { start, end } = getDayRange(today); // const { start, end } = getDayRange(today);
// Get today's work entries for this VA and project // // Get today's work entries for this VA and project
// Exclude the current session if it's already in the database // // Exclude the current session if it's already in the database
const query = { // const query = {
vaId, // vaId,
projectId, // projectId,
from: { $gte: start, $lte: end } // from: { $gte: start, $lte: end }
}; // };
if (currentSessionId) { // if (currentSessionId) {
query._id = { $ne: currentSessionId }; // query._id = { $ne: currentSessionId };
} // }
const todayWorkEntries = await WorkTrackModel.find(query); // const todayWorkEntries = await WorkTrackModel.find(query);
// Calculate total hours worked today // // Calculate total hours worked today
let totalTodayMs = todayWorkEntries.reduce((total, entry) => { // let totalTodayMs = todayWorkEntries.reduce((total, entry) => {
return total + (entry.totalTime || 0); // return total + (entry.totalTime || 0);
}, 0); // }, 0);
// Add the additional time from current session if provided // // Add the additional time from current session if provided
totalTodayMs += additionalTimeMs; // totalTodayMs += additionalTimeMs;
const totalTodayHours = totalTodayMs / (1000 * 60 * 60); // const totalTodayHours = totalTodayMs / (1000 * 60 * 60);
// Check if daily dedicated hours are completed // // Check if daily dedicated hours are completed
if (project.dedicatedHours > 0 && totalTodayHours >= project.dedicatedHours) { // if (project.dedicatedHours > 0 && totalTodayHours >= project.dedicatedHours) {
// Create automatic logout log // // Create automatic logout log
const logoutLog = await LoggedModel.create({ // const logoutLog = await LoggedModel.create({
loggedType: "LOGOUT", // loggedType: "LOGOUT",
vaId, // vaId,
vaEmail, // vaEmail,
projectId // projectId
}); // });
try { // try {
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`; // const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
const sfDataSync = { // const sfDataSync = {
"_id": logoutLog._id, // "_id": logoutLog._id,
"loggedType": "LOGOUT", // "loggedType": "LOGOUT",
"vaId": vaId, // "vaId": vaId,
"vaEmail": vaEmail, // "vaEmail": vaEmail,
"projectId": projectId, // "projectId": projectId,
"time": logoutLog.time, // "time": logoutLog.time,
} // }
const response = await fetch(salesforceUrl, { // const response = await fetch(salesforceUrl, {
method: 'POST', // method: 'POST',
headers: { // headers: {
'Content-Type': 'application/json', // 'Content-Type': 'application/json',
// Add any required headers for Salesforce authentication // // Add any required headers for Salesforce authentication
// 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN // // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
}, // },
body: JSON.stringify(sfDataSync) // body: JSON.stringify(sfDataSync)
}); // });
if (!response.ok) { // if (!response.ok) {
const errorText = await response.text(); // const errorText = await response.text();
throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`); // throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
} // }
const result = await response.json(); // const result = await response.json();
// console.log("✅ Successfully synced VA data to Salesforce:", result); // // console.log("✅ Successfully synced VA data to Salesforce:", result);
return { // return {
shouldLogout: true, // shouldLogout: true,
totalTodayHours: totalTodayHours, // totalTodayHours: totalTodayHours,
dedicatedHours: project.dedicatedHours, // dedicatedHours: project.dedicatedHours,
logoutLogId: logoutLog._id, // logoutLogId: logoutLog._id,
SyncSalesforce: result // SyncSalesforce: result
}; // };
} catch (error) { // } catch (error) {
console.error("Failed to sync with Salesforce:", error.message); // console.error("Failed to sync with Salesforce:", error.message);
console.log(error) // console.log(error)
// Create a custom error to distinguish Salesforce errors // // Create a custom error to distinguish Salesforce errors
const salesforceError = new Error(`Salesforce sync failed: ${error.message}`); // const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
salesforceError.isSalesforceError = true; // salesforceError.isSalesforceError = true;
throw salesforceError; // throw salesforceError;
} // }
} // }
return { // return {
shouldLogout: false, // shouldLogout: false,
totalTodayHours: totalTodayHours, // totalTodayHours: totalTodayHours,
dedicatedHours: project.dedicatedHours // dedicatedHours: project.dedicatedHours
}; // };
} catch (error) { // } catch (error) {
console.error("Error in checkDailyHoursAndLogout:", error); // console.error("Error in checkDailyHoursAndLogout:", error);
return { shouldLogout: false, error: error.message }; // return { shouldLogout: false, error: error.message };
} // }
} // }
// Getting logged hours in a specific project of VA // Getting logged hours in a specific project of VA
async function va_log_track_work_callback(req, res) { async function va_log_track_work_callback(req, res) {
try { try {
const accessToken = req.headers.token; // const accessToken = req.headers.token;
if (!accessToken) { // if (!accessToken) {
return res.status(401).json({ success: false, message: "Missing token" }); // return res.status(401).json({ success: false, message: "Missing token" });
} // }
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN); // const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
if (!decoded || !decoded.email) { // if (!decoded || !decoded.email) {
return res.status(401).json({ success: false, message: "Invalid token" }); // return res.status(401).json({ success: false, message: "Invalid token" });
} // }
const vaEmail = decoded.email; // const vaEmail = decoded.email;
const vaUser = await VAModel.findOne({ "userDetails.email": vaEmail }); // const vaUser = await VAModel.findOne({ "userDetails.email": vaEmail });
if (!vaUser) { // if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found" }); // return res.status(404).json({ success: false, message: "VA not found" });
} // }
const vaId = vaUser._id; // const vaId = vaUser._id;
const payload = req.body || {}; const payload = req.body || {};
const { projectId, logId, title, appName } = payload;
let { vaId, projectId, logId, logs } = payload;
if (!projectId || !logId || !vaId || !Array.isArray(logs)) {
return res.status(400).json({ success: false, message: "Missing Props" });
}
const vaUser = await VAModel.findById(vaId);
if (!vaUser) return res.status(400).json({ success: false, message: "VA not found" });
const project = await ProjectModel.findById(projectId); const project = await ProjectModel.findById(projectId);
if (!project) return res.status(400).json({ success: false, message: "Project not found" }); if (!project) return res.status(400).json({ success: false, message: "Project not found" });
@ -155,116 +163,137 @@ async function va_log_track_work_callback(req, res) {
const logEntry = await LoggedModel.findById(logId); const logEntry = await LoggedModel.findById(logId);
if (!logEntry) return res.status(400).json({ success: false, message: "Log entry not found" }); if (!logEntry) return res.status(400).json({ success: false, message: "Log entry not found" });
// Check last log entry for this VA and project if (logs.length === 0) {
const lastLog = await LoggedModel.findOne({ vaId, projectId }) return res.status(400).json({ success: false, message: "No logs to track" });
.sort({ time: -1 }) // get the most recent log
.lean();
// If the last log is a LOGOUT, prevent new work tracking
if (lastLog.loggedType === "LOGOUT") {
return res.status(400).json({
success: false,
message: "User has already logged out from this project",
});
} }
if (!projectId || !logId || !title || !appName) { logs[0].from = logEntry.time; // Ensure the first log's 'from' matches the main log time
return res.status(400).json({ success: false, message: "Missing Props" });
} const firstFromLogTime = new Date(logs[0].from);
const firstToLogTime = new Date(logs[0].to);
const firstTotalLogTime = Math.abs(firstToLogTime - firstFromLogTime);
logs[0].totalTime = firstTotalLogTime;
// // Check last log entry for this VA and project
// const lastLog = await LoggedModel.findOne({ vaId, projectId })
// .sort({ time: -1 }) // get the most recent log
// .lean();
// // If the last log is a LOGOUT, prevent new work tracking
// if (lastLog.loggedType === "LOGOUT") {
// return res.status(400).json({
// success: false,
// message: "User has already logged out from this project",
// });
// }
// Handle POST → create entry // Handle POST → create entry
if (req.method === "POST") { // if (req.method === "POST") {
const dailyCheck = await checkDailyHoursAndLogout(vaId, vaEmail, projectId, project); // const dailyCheck = await checkDailyHoursAndLogout(vaId, vaEmail, projectId, project);
// const createdTracks = await Promise.all(
// logs.map(async (log) => {
// // Calculate total time from timestamps
// const fromTime = new Date(log.from);
// const toTime = new Date(log.to);
// const totalTime = Math.abs(toTime - fromTime);
// const createdTrack = await WorkTrackModel.create({
// vaId,
// projectId,
// logId,
// title: log.title,
// appName: log.appName,
// url: log.url,
// from: log.from,
// to: log.to,
// totalTime: totalTime,
// });
if (dailyCheck.shouldLogout) { // return createdTrack;
return res.status(400).json({ // })
success: false, // );
message: `Cannot start work - daily hours completed (${formatHoursToReadable(dailyCheck.totalTodayHours)} / ${formatHoursToReadable(dailyCheck.dedicatedHours)})`,
autoLogout: true const fromLogTime = new Date(logs[0].from);
}); const toLogTime = new Date(logs[logs.length -1].to);
} const totalLogTime = Math.abs(toLogTime - fromLogTime);
const createdTrack = await WorkTrackModel.create({ const createdTrack = await WorkTrackModel.create({
vaId, vaId,
projectId, projectId,
logId, logId,
title, from: logs[0].from,
appName, to: logs[logs.length -1].to,
from: Date.now(), totalTime: totalLogTime,
logs: logs
}); });
return res.status(201).json({ return res.status(201).json({
success: true, success: true,
message: "Work tracking started", message: "Work tracking created",
data: createdTrack, data: createdTrack
dailyHours: {
todayTotal: formatHoursToReadable(dailyCheck.totalTodayHours),
dailyLimit: formatHoursToReadable(dailyCheck.dedicatedHours),
remaining: formatHoursToReadable(dailyCheck.dedicatedHours - dailyCheck.totalTodayHours)
}
}); });
} // }
// Handle PATCH → update entry (add 'to' and calculate total time) // // Handle PATCH → update entry (add 'to' and calculate total time)
if (req.method === "PATCH") { // if (req.method === "PATCH") {
const { id } = payload; // expecting the workTrack record ID to update // const { id } = payload; // expecting the workTrack record ID to update
if (!id) { // if (!id) {
return res.status(400).json({ success: false, message: "Missing workTrack ID" }); // return res.status(400).json({ success: false, message: "Missing workTrack ID" });
} // }
const workEntry = await WorkTrackModel.findById(id); // const workEntry = await WorkTrackModel.findById(id);
if (!workEntry) { // if (!workEntry) {
return res.status(404).json({ success: false, message: "Work entry not found" }); // return res.status(404).json({ success: false, message: "Work entry not found" });
} // }
const toTime = new Date(); // const toTime = new Date();
const fromTime = new Date(workEntry.from); // const fromTime = new Date(workEntry.from);
const totalTimeMs = toTime - fromTime; // const totalTimeMs = toTime - fromTime;
// Optional: store totalTime in minutes or hours instead of raw ms // // Optional: store totalTime in minutes or hours instead of raw ms
const hours = Math.floor(totalTimeMs / (1000 * 60 * 60)); // const hours = Math.floor(totalTimeMs / (1000 * 60 * 60));
const minutes = Math.floor((totalTimeMs % (1000 * 60 * 60)) / (1000 * 60)); // const minutes = Math.floor((totalTimeMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((totalTimeMs % (1000 * 60)) / 1000); // const seconds = Math.floor((totalTimeMs % (1000 * 60)) / 1000);
const formattedTotalTime = `${hours}h ${minutes}m ${seconds}s`; // const formattedTotalTime = `${hours}h ${minutes}m ${seconds}s`;
// Check if daily dedicated hours are completed after this work session // // Check if daily dedicated hours are completed after this work session
const dailyCheck = await checkDailyHoursAndLogout(vaId, vaEmail, projectId, project, id, totalTimeMs); // const dailyCheck = await checkDailyHoursAndLogout(vaId, vaEmail, projectId, project, id, totalTimeMs);
workEntry.to = toTime; // workEntry.to = toTime;
workEntry.totalTime = totalTimeMs; // store as number of minutes // workEntry.totalTime = totalTimeMs; // store as number of minutes
await workEntry.save(); // await workEntry.save();
const response = { // const response = {
success: true, // success: true,
message: "Work tracking stopped", // message: "Work tracking stopped",
data: { // data: {
id: workEntry._id, // id: workEntry._id,
from: workEntry.from, // from: workEntry.from,
to: workEntry.to, // to: workEntry.to,
formattedTotalTime, // formattedTotalTime,
totalTimeMs, // totalTimeMs,
sessionHours: totalTimeMs / (1000 * 60 * 60) // sessionHours: totalTimeMs / (1000 * 60 * 60)
}, // },
dailyHours: { // dailyHours: {
todayTotal: formatHoursToReadable(dailyCheck.totalTodayHours), // todayTotal: formatHoursToReadable(dailyCheck.totalTodayHours),
dailyLimit: formatHoursToReadable(dailyCheck.dedicatedHours), // dailyLimit: formatHoursToReadable(dailyCheck.dedicatedHours),
remaining: formatHoursToReadable(Math.max(0, dailyCheck.dedicatedHours - dailyCheck.totalTodayHours)) // remaining: formatHoursToReadable(Math.max(0, dailyCheck.dedicatedHours - dailyCheck.totalTodayHours))
} // }
}; // };
// If auto-logout was triggered, add that info to response // // If auto-logout was triggered, add that info to response
if (dailyCheck.shouldLogout) { // if (dailyCheck.shouldLogout) {
response.autoLogout = true; // response.autoLogout = true;
response.message = `Work tracking stopped. Auto-logout created - daily hours completed (${formatHoursToReadable(dailyCheck.totalTodayHours)} / ${formatHoursToReadable(dailyCheck.dedicatedHours)})`; // response.message = `Work tracking stopped. Auto-logout created - daily hours completed (${formatHoursToReadable(dailyCheck.totalTodayHours)} / ${formatHoursToReadable(dailyCheck.dedicatedHours)})`;
response.logoutLogId = dailyCheck.logoutLogId; // response.logoutLogId = dailyCheck.logoutLogId;
} // }
return res.status(200).json(response); // return res.status(200).json(response);
} // }
// Fallback for unsupported methods // Fallback for unsupported methods
return res.status(405).json({ success: false, message: "Method not allowed" }); // return res.status(405).json({ success: false, message: "Method not allowed" });
} catch (error) { } catch (error) {
return res.status(500).send("Internal Server Error"); return res.status(500).send("Internal Server Error");
} }

View File

@ -0,0 +1,47 @@
import { VAModel } from "../../../models/vaModel.js";
import authFetch from "../../../utils/authFetch.js";
async function va_payslip_data_function(req, res) {
try {
const vaId = req.params.vaId;
const vaUser = await VAModel.findById(vaId);
if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found" });
}
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-payslip/${vaId}`;
const response = await authFetch(salesforceUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
// Add any required headers for Salesforce authentication
// 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
},
});
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);
res.status(201).json({
success: true,
data: result.data
});
} 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;
}
}
export { va_payslip_data_function };

View File

@ -0,0 +1,76 @@
import authFetch from "../../../utils/authFetch.js";
async function va_payroll_schedule_function(req, res) {
try {
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/payroll-schedule`;
const response = await authFetch(salesforceUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
// Add any required headers for Salesforce authentication
// 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
}
const result = await response.json();
// Get current date
const currentDate = new Date();
// Sort all data by ScheduleDate in ascending order
const sortedData = [...result.data].sort((a, b) => {
return new Date(a.ScheduleDate) - new Date(b.ScheduleDate);
});
// Find the index of current schedule (where current date falls between periodStart and periodEnd)
let currentIndex = -1;
for (let i = 0; i < sortedData.length; i++) {
const item = sortedData[i];
const periodStart = new Date(item.periodStart);
const periodEnd = new Date(item.periodEnd);
if (currentDate >= periodStart && currentDate <= periodEnd) {
currentIndex = i;
break;
}
}
// If no current schedule found, use the schedule that just passed or is upcoming
if (currentIndex === -1) {
// Find the first schedule where periodEnd is in future
currentIndex = sortedData.findIndex(item => new Date(item.periodEnd) >= currentDate);
// If all are in past, use the last one
if (currentIndex === -1) {
currentIndex = sortedData.length - 1;
}
}
// Calculate the start index (2nd last schedule from current)
const startIndex = Math.max(0, currentIndex - 2);
// Get the filtered data from 2nd last to all future schedules
const filteredData = sortedData.slice(startIndex);
res.status(201).json({
success: true,
data: filteredData
});
} 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;
}
}
export { va_payroll_schedule_function };

View File

@ -0,0 +1,92 @@
import { VAModel, ClientModel, ProjectModel, LoggedModel, WorkTrackModel } from "../../../models/vaModel.js";
// Delete VA and all related data
async function va_remove_function(req, res) {
try {
const vaId = req.params.id;
// Validate VA ID
if (!vaId) {
return res.status(400).json({
success: false,
message: "VA ID is required"
});
}
// Find the VA to get clientIds and projectIds before deletion
const vaUser = await VAModel.findById(vaId);
if (!vaUser) {
return res.status(404).json({
success: false,
message: "VA not found"
});
}
const clientIds = vaUser.clientIds || [];
const projectIds = vaUser.projectIds || [];
const vaEmail = vaUser.userDetails?.email;
console.log(`Deleting VA ${vaId} - Affected: ${clientIds.length} clients, ${projectIds.length} projects`);
// 1. Remove VA from all Clients
let clientsUpdated = 0;
if (clientIds.length > 0) {
const clientResult = await ClientModel.updateMany(
{ _id: { $in: clientIds } },
{ $pull: { vaIds: vaId } }
);
clientsUpdated = clientResult.modifiedCount;
console.log(`Removed VA from ${clientsUpdated} clients`);
}
// 2. Remove VA from all Projects
let projectsUpdated = 0;
if (projectIds.length > 0) {
const projectResult = await ProjectModel.updateMany(
{ _id: { $in: projectIds } },
{ $pull: { "vaAssigned.vaId": vaId } }
);
projectsUpdated = projectResult.modifiedCount;
console.log(`Removed VA from ${projectsUpdated} projects`);
}
// 3. Delete all WorkTrack records for this VA
const workTrackResult = await WorkTrackModel.deleteMany({ vaId: vaId });
console.log(`Deleted ${workTrackResult.deletedCount} work track records`);
// 4. Delete all Logged records for this VA
const loggedResult = await LoggedModel.deleteMany({ vaId: vaId });
console.log(`Deleted ${loggedResult.deletedCount} log records`);
// 5. Finally delete the VA
await VAModel.findByIdAndDelete(vaId);
console.log(`Successfully deleted VA: ${vaId} and all related data`);
res.status(200).json({
success: true,
message: 'Virtual Assistant and all related data deleted successfully',
data: {
vaId: vaId,
vaName: `${vaUser.userDetails?.firstName || ''} ${vaUser.userDetails?.lastName || ''}`.trim() || 'Unknown VA',
vaEmail: vaEmail,
clientsUpdated: clientsUpdated,
projectsUpdated: projectsUpdated,
workTracksDeleted: workTrackResult.deletedCount,
logsDeleted: loggedResult.deletedCount,
deletedAt: new Date().toISOString()
}
});
} catch (error) {
console.error('Error deleting VA:', error);
res.status(500).json({
success: false,
message: 'Failed to delete Virtual Assistant',
error: 'Internal server error'
});
}
}
export { va_remove_function };

View File

@ -5,52 +5,96 @@ import { VAModel } from "../../../models/vaModel.js";
// Register a new VA // Register a new VA
async function va_profile_update_callback(req, res) { async function va_profile_update_callback(req, res) {
try { try {
const sf_token = req.headers["x-auth-token"];
const accessToken = req.headers.token; const accessToken = req.headers.token;
if (!accessToken) { let vaUser;
return res.status(401).json({ success: false, message: "Missing access token" }); let authSource = 'regular';
if(sf_token) {
try {
const sfDecoded = jwt.verify(sf_token, process.env.JWT_SECRET_SF_TOKEN);
if (!sfDecoded?.email && !sfDecoded?.portalId) {
return res.status(400).json({ success: false, message: "Invalid Salesforce token payload" });
}
// Find user by email or VA ID from Salesforce token
if (sfDecoded.portalId) {
vaUser = await VAModel.findById(sfDecoded.portalId);
} else if (sfDecoded.email) {
vaUser = await VAModel.findOne({ "userDetails.email": sfDecoded.email });
}
if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found with Salesforce token" });
}
authSource = 'salesforce';
} catch (sfError) {
return res.status(401).json({ success: false, message: "Invalid or expired Salesforce token" });
}
} else {
try {
if (!accessToken) {
return res.status(401).json({ success: false, message: "Missing access token" });
}
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
if (!decoded?.email) {
return res.status(400).json({ success: false, message: "Invalid token payload" });
}
vaUser = await VAModel.findOne({ "userDetails.email": decoded.email });
if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found" });
}
authSource = 'regular';
} catch (tokenError) {
return res.status(401).json({ success: false, message: "Invalid or expired access token" });
}
} }
let decoded;
try {
decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
} catch (err) {
return res.status(401).json({ success: false, message: "Invalid or expired token" });
}
if (!decoded?.email) {
return res.status(400).json({ success: false, message: "Invalid token payload" });
}
const vaUser = await VAModel.findOne({ "userDetails.email": decoded.email });
if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found" });
}
const payload = req.body || {}; const payload = req.body || {};
const { userDetails } = payload; const { userDetails, status } = payload;
if (!userDetails) { if (!userDetails) {
return res.status(400).json({ success: false, message: "Missing userDetails" }); return res.status(400).json({ success: false, message: "Missing userDetails" });
} }
// Allowed fields to update const allowedFields = [
// const allowedFields = [ "firstName", "lastName", "dob", "age", "address",
// "firstName", "lastName", "dob", "age", "address", "city", "state", "country", "phone", "skype",
// "bankName", "accountHolderName", "accountNumber", "routingNumber" "paymentMethod", "payoneerAccount", "veemAccount", "wiseEmail"
// ]; ];
// // Update allowed fields only let hasUpdates = false;
// allowedFields.forEach(field => { const updatedFields = [];
// if (userDetails[field] !== undefined) {
// vaUser.userDetails[field] = userDetails[field]; // Update allowed fields if provided
// } allowedFields.forEach(field => {
// }); if (userDetails[field] !== undefined && userDetails[field] !== null && userDetails[field] !== '') {
vaUser.userDetails[field] = userDetails[field];
hasUpdates = true;
updatedFields.push(field);
}
});
/* ---------------------- PASSWORD UPDATE ---------------------- */ /* ---------------------- PASSWORD UPDATE ---------------------- */
let passwordUpdated = false;
if (userDetails.password || userDetails.cPassword || userDetails.oldPassword) { if (userDetails.password || userDetails.cPassword || userDetails.oldPassword) {
const { oldPassword, password, cPassword } = userDetails; const { oldPassword, password, cPassword } = userDetails;
if (authSource === 'salesforce') {
return res.status(403).json({
success: false,
message: "Password changes require regular authentication",
});
}
if (!oldPassword || !password || !cPassword) { if (!oldPassword || !password || !cPassword) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@ -78,11 +122,21 @@ async function va_profile_update_callback(req, res) {
const saltRounds = 10; const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds); const hashedPassword = await bcrypt.hash(password, saltRounds);
vaUser.userDetails.password = hashedPassword; vaUser.userDetails.password = hashedPassword;
} else { hasUpdates = true;
passwordUpdated = true;
updatedFields.push('password');
}
if (!hasUpdates) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "Data not found", message: "No valid fields to update",
}); });
}
if (status) {
vaUser.status = status;
updatedFields.push('status');
} }
await vaUser.save(); await vaUser.save();
@ -95,6 +149,7 @@ async function va_profile_update_callback(req, res) {
success: true, success: true,
message: "Profile updated successfully", message: "Profile updated successfully",
data: updatedUser, data: updatedUser,
updatedFields: updatedFields,
}); });
} catch (error) { } catch (error) {
return res.status(500).json({ success: false, message: "Internal Server Error" }); return res.status(500).json({ success: false, message: "Internal Server Error" });

View File

@ -1,5 +1,6 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { VAModel } from "../../../models/vaModel.js"; import { VAModel } from "../../../models/vaModel.js";
import authFetch from "../../../utils/authFetch.js";
// Register a new VA // Register a new VA
async function va_register(req, res) { async function va_register(req, res) {
@ -36,6 +37,7 @@ async function va_register(req, res) {
paymentMethod: userDetails.paymentMethod || null, paymentMethod: userDetails.paymentMethod || null,
payoneerAccount: userDetails.payoneerAccount || null, payoneerAccount: userDetails.payoneerAccount || null,
veemAccount: userDetails.veemAccount || null, veemAccount: userDetails.veemAccount || null,
wiseEmail: userDetails.wiseEmail || null,
phone: userDetails.phone || null, phone: userDetails.phone || null,
skype: userDetails.skype || null, skype: userDetails.skype || null,
}, },
@ -52,7 +54,7 @@ async function va_register(req, res) {
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/virtualassistant`; const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/virtualassistant`;
delete created.userDetails._id; delete created.userDetails._id;
created.userDetails._id = created._id; // Include VA's MongoDB ID created.userDetails._id = created._id; // Include VA's MongoDB ID
const response = await fetch(salesforceUrl, { const response = await authFetch(salesforceUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -73,7 +75,7 @@ async function va_register(req, res) {
res.status(201).json({ success: true, data: created, SyncSalesforce: result }); res.status(201).json({ success: true, data: created, SyncSalesforce: result });
} catch (error) { } catch (error) {
console.error("❌ Failed to sync with Salesforce:", error.message); console.error("❌ Failed to sync with Salesforce:", error.message);
await VAModel.findOneAndDelete({_id: created.userDetails._id});
// Create a custom error to distinguish Salesforce errors // Create a custom error to distinguish Salesforce errors
const salesforceError = new Error(`Salesforce sync failed: ${error.message}`); const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
salesforceError.isSalesforceError = true; salesforceError.isSalesforceError = true;

View File

@ -1,12 +1,15 @@
import { LoggedModel, WorkTrackModel, ProjectModel } from "../../../models/vaModel.js"; import mongoose from "mongoose";
import { LoggedModel, WorkTrackModel, ProjectModel, VAModel } from "../../../models/vaModel.js";
import authFetch from "../../../utils/authFetch.js";
import getDayNumber from "../../../utils/getDayNumber.js";
// Helper function to get start and end of day // Helper function to get start and end of day in UTC
function getDayRange(date = new Date()) { function getDayRange(date = new Date()) {
const start = new Date(date); const start = new Date(date);
start.setHours(0, 0, 0, 0); start.setUTCHours(0, 0, 0, 0);
const end = new Date(date); const end = new Date(date);
end.setHours(23, 59, 59, 999); end.setUTCHours(23, 59, 59, 999);
return { start, end }; return { start, end };
} }
@ -25,10 +28,130 @@ function formatHoursToReadable(hours) {
} }
} }
// function to close open work tracks and create logout // Helper function to parse ISO time string
async function closePreviousSession(vaId, lastLog) { function parseISOTimeString(timeString) {
if (!timeString) return new Date();
try { try {
const currentTime = new Date(); // Parse ISO string directly
const parsedDate = new Date(timeString);
// Check if the date is valid
if (isNaN(parsedDate.getTime())) {
throw new Error('Invalid date');
}
parsedDate.setMilliseconds(0);
console.log("pasdes date" + parsedDate)
return parsedDate;
} catch (error) {
console.error('Error parsing ISO time string:', error);
return new Date();
}
}
// Helper function to check if time difference is less than threshold
function isTimeWithinThreshold(receivedTime, currentTime, thresholdSeconds = 10) {
const diffMs = Math.abs(currentTime - receivedTime);
const diffSeconds = diffMs / 1000;
console.log(`Time comparison - Received: ${receivedTime.toISOString()}, Current: ${currentTime.toISOString()}, Diff: ${diffSeconds}s`);
return diffSeconds <= thresholdSeconds;
}
// Helper function to get current UTC time
function getCurrentUTCTime() {
const now = new Date();
now.setMilliseconds(0);
return now;
}
// function parseTimeToMinutes(timeString) {
// if (!timeString) return null;
// const [hours, minutes] = timeString.split(':').map(Number);
// return hours * 60 + (minutes || 0);
// }
// function parseDelayToMinutes(delayString) {
// if (!delayString) return 0;
// if (delayString.includes('h')) {
// const hours = parseInt(delayString.replace('h', '').trim());
// return hours * 60;
// } else if (delayString.includes('m')) {
// return parseInt(delayString.replace('m', '').trim());
// }
// return parseInt(delayString) || 0; // Default to minutes if no unit specified
// }
// function checkScheduleCompliance(currentTime, project, vaStatus, type) {
// const warnings = [];
// // If project doesn't have schedule or VA is PARTTIME with flexible allowed, no schedule restrictions
// if (!project.vaWorkingScheduleStart || !project.vaWorkingScheduleEnd) {
// return { isCompliant: true, warnings };
// }
// const currentDate = new Date(currentTime);
// const currentMinutes = currentDate.getUTCHours() * 60 + currentDate.getUTCMinutes();
// const scheduleStartMinutes = parseTimeToMinutes(project.vaWorkingScheduleStart);
// const scheduleEndMinutes = parseTimeToMinutes(project.vaWorkingScheduleEnd);
// const allowedStartDelay = parseDelayToMinutes(project.vaWorkingDelayAllowedStart);
// const allowedEndDelay = parseDelayToMinutes(project.vaWorkingDelayAllowedEnd);
// // Calculate allowed time ranges
// const allowedStartMinutes = scheduleStartMinutes - allowedStartDelay;
// const allowedEndMinutes = scheduleEndMinutes + allowedEndDelay;
// console.log(`Schedule Check - Current: ${currentMinutes}, Start: ${scheduleStartMinutes}, End: ${scheduleEndMinutes}, Allowed Start: ${allowedStartMinutes}, Allowed End: ${allowedEndMinutes}`);
// // Check for PARTTIME with flexible schedule
// if (vaStatus === "PARTTIME" && project.isFlexibleAllowed) {
// return { isCompliant: true, warnings };
// }
// // For all other cases, check schedule compliance
// if (type === "LOGIN") {
// // Check if login is too early
// if (currentMinutes < allowedStartMinutes) {
// warnings.push("early_login");
// }
// // Check if login is after shift end
// if (currentMinutes > scheduleEndMinutes) {
// warnings.push("working_after_shift_end");
// }
// // Check if login is within allowed range
// // const isWithinAllowedRange = currentMinutes >= allowedStartMinutes && currentMinutes <= allowedEndMinutes;
// return {
// isCompliant: true, // Allow login regardless of time
// warnings
// };
// } else if (type === "LOGOUT") {
// // Check if logout is too late
// if (currentMinutes > allowedEndMinutes) {
// warnings.push("late_logout");
// }
// // For logout, we're generally more permissive - allow logout even if late
// return {
// isCompliant: true, // Allow logout regardless of time
// warnings
// };
// }
// return { isCompliant: true, warnings };
// }
// function to close open work tracks and create logout
async function closePreviousSession(vaId, lastLog, logTime = new Date()) {
try {
const currentTime = logTime;
// Find all open work tracks for this VA and project (where to is not set or totalTime is 0) // Find all open work tracks for this VA and project (where to is not set or totalTime is 0)
const openWorkTracks = await WorkTrackModel.find({ const openWorkTracks = await WorkTrackModel.find({
@ -69,15 +192,15 @@ async function closePreviousSession(vaId, lastLog) {
try { try {
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`; const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
const sfDataSync = { const sfDataSync = {
"_id": logoutLog._id, "_id": logoutLog._id,
"loggedType": "LOGOUT", "loggedType": "LOGOUT",
"vaId": vaId, "vaId": vaId,
"vaEmail": lastLog.vaEmail, "vaEmail": lastLog.vaEmail,
"projectId": lastLog.projectId, "projectId": lastLog.projectId,
"time": logoutLog.time, "time": logoutLog.time,
} }
const response = await fetch(salesforceUrl, { const response = await authFetch(salesforceUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -120,18 +243,181 @@ async function closePreviousSession(vaId, lastLog) {
async function va_project_log_callback(req, res) { async function va_project_log_callback(req, res) {
try { try {
const payload = req.body || {}; const payload = req.body || {};
const { vaId, vaEmail, projectId, type } = payload; const { vaId, vaEmail, projectId, type, time: receivedTimeString } = payload;
// console.log("Received payload:", payload);
if (!vaId, !vaEmail, !projectId, !type) return res.status(400).send("Missing Props"); if (!vaId || !vaEmail || !projectId || !type) {
return res.status(400).send("Missing required properties");
}
// Determine the log time based on received time (both in UTC)
const currentTime = getCurrentUTCTime(); // Current UTC time
let logTime = currentTime;
if (receivedTimeString) {
// Parse the received ISO time string
const receivedTime = parseISOTimeString(receivedTimeString);
// Check if the difference is less than 10 seconds
if (isTimeWithinThreshold(receivedTime, currentTime, 10)) {
logTime = receivedTime;
console.log(`✅ Using received ISO time: ${receivedTimeString}, difference is within 10 seconds`);
console.log(`✅ Parsed as: ${receivedTime.toISOString()}`);
} else {
logTime = currentTime;
console.log(`⚠️ Using current UTC time, received time difference exceeds 10 seconds.`);
console.log(`Received: ${receivedTimeString} (parsed: ${receivedTime.toISOString()})`);
console.log(`Current: ${currentTime.toISOString()}`);
}
}
const project = await ProjectModel.findById(projectId); const project = await ProjectModel.findById(projectId);
if (!project) { if (!project) {
return res.status(400).json({ success: false, message: "Project not found" }); return res.status(400).json({ success: false, message: "Project not found" });
} }
// For LOGIN requests, check daily hours const vaUser = await VAModel.findById(vaId);
if (!vaUser) {
return res.status(400).json({ success: false, message: "VA not found" });
}
// Check schedule compliance
// const scheduleCheck = checkScheduleCompliance(logTime, project, vaUser.status, type);
// console.log(`Schedule compliance check:`, scheduleCheck);
// if (!scheduleCheck.isCompliant) {
// return res.status(400).json({
// success: false,
// message: `Cannot ${type.toLowerCase()} - outside of allowed schedule time`,
// warnings: scheduleCheck.warnings
// });
// }
// If Logout then Update work track entries
if (type === "LOGOUT") {
const { logId } = payload;
const workTrackData = await WorkTrackModel.find({
logId: new mongoose.Types.ObjectId(logId)
});
// console.log(`Found work track data for logout:`, workTrackData);
if (workTrackData[0]) {
const fromLogTime = new Date(workTrackData[0].from);
const toLogTime = new Date(logTime);
const totalTimeMs = toLogTime - fromLogTime;
const updatedLogs = workTrackData[0].logs ? [...workTrackData[0].logs] : [];
if (updatedLogs.length > 0) {
const lastLogIndex = updatedLogs.length - 1;
const lastLogEntry = updatedLogs[lastLogIndex];
// Update the last log's 'to' time and recalculate its totalTime
const lastLogFromTime = new Date(lastLogEntry.from);
const lastLogTotalTime = Math.abs(toLogTime - lastLogFromTime);
updatedLogs[lastLogIndex] = {
...lastLogEntry,
to: toLogTime,
totalTime: lastLogTotalTime
};
// console.log(`Updated last log in array - from: ${lastLogFromTime.toISOString()}, to: ${toLogTime.toISOString()}, totalTime: ${lastLogTotalTime}ms`);
}
const updateResult = await WorkTrackModel.updateOne(
{ _id: workTrackData[0]._id },
{
$set: {
to: toLogTime,
totalTime: totalTimeMs,
logs: updatedLogs
}
}
);
console.log(`Updated work track ${workTrackData[0]._id}:`, {
from: fromLogTime.toISOString(),
to: toLogTime.toISOString(),
totalTime: totalTimeMs,
logsUpdated: updatedLogs.length,
mongodbUpdateResult: updateResult
});
// const updatedWorkTrack = await WorkTrackModel.findById(workTrackData[0]._id);
// console.log("Verified updated work track:", {
// to: updatedWorkTrack?.to,
// totalTime: updatedWorkTrack?.totalTime,
// logsCount: updatedWorkTrack?.logs?.length,
// lastLog: updatedWorkTrack?.logs?.[updatedWorkTrack.logs.length - 1]
// });
} else {
console.log(`No work track found for vaId: ${vaId}, projectId: ${projectId}, logId: ${logId}`);
}
// Create the logout log entry
const createdLog = await LoggedModel.create({
loggedType: type,
vaId,
vaEmail,
projectId,
time: logTime
});
try {
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
const sfData = {
"_id": createdLog._id,
"loggedType": type,
"vaId": vaId,
"vaEmail": vaEmail,
"projectId": projectId,
"time": createdLog.time,
}
const response = await authFetch(salesforceUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(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();
return res.status(201).json({
success: true,
message: `${type} Successfully`,
logId: createdLog._id,
SyncSalesforce: result,
timeUsed: receivedTimeString ? 'received' : 'current',
actualTime: createdLog.time,
});
} catch (error) {
console.error("Failed to sync with Salesforce:", error.message);
console.log(error)
// Still return success since the logout was recorded locally
return res.status(201).json({
success: true,
message: `${type} Successfully (Salesforce sync failed)`,
logId: createdLog._id,
timeUsed: receivedTimeString ? 'received' : 'current',
actualTime: createdLog.time,
warning: "Salesforce sync failed"
});
}
}
// For LOGIN requests, check daily hours (using UTC day range)
if (type === "LOGIN") { if (type === "LOGIN") {
const { start, end } = getDayRange(); const { start, end } = getDayRange(); // UTC day range
// Get today's total hours // Get today's total hours
const todayWorkEntries = await WorkTrackModel.find({ const todayWorkEntries = await WorkTrackModel.find({
vaId, vaId,
@ -145,11 +431,14 @@ async function va_project_log_callback(req, res) {
const totalTodayHours = totalTodayMs / (1000 * 60 * 60); const totalTodayHours = totalTodayMs / (1000 * 60 * 60);
const dayNumber = getDayNumber(logTime);
// Check if daily hours are already completed // Check if daily hours are already completed
if (project.dedicatedHours > 0 && totalTodayHours >= project.dedicatedHours) { if (project.dedicatedHours[dayNumber] > 0 && totalTodayHours >= project.dedicatedHours[dayNumber]) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: `Cannot login - daily hours already completed (${formatHoursToReadable(totalTodayHours)} / ${formatHoursToReadable(project.dedicatedHours)})` message: `Cannot login - daily hours already completed (${formatHoursToReadable(totalTodayHours)} / ${formatHoursToReadable(project.dedicatedHours[dayNumber])})`,
// warnings: scheduleCheck.warnings
}); });
} }
@ -163,7 +452,7 @@ async function va_project_log_callback(req, res) {
// check last workModel login time and make a logout request then create login // check last workModel login time and make a logout request then create login
// console.log(`User already logged in. Closing previous session...`); // console.log(`User already logged in. Closing previous session...`);
const closeResult = await closePreviousSession(vaId, lastLog); const closeResult = await closePreviousSession(vaId, lastLog, logTime);
// console.log(`Previous session closed: ${closeResult.closedWorkTracks} work tracks, logout log: ${closeResult.logoutLogId}`); // console.log(`Previous session closed: ${closeResult.closedWorkTracks} work tracks, logout log: ${closeResult.logoutLogId}`);
@ -172,66 +461,122 @@ async function va_project_log_callback(req, res) {
loggedType: "LOGIN", loggedType: "LOGIN",
vaId, vaId,
vaEmail, vaEmail,
projectId projectId,
time: logTime
}); });
return res.status(201).send({ try {
success: true, const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
message: `Login Successful (previous session closed)`, const sfData = {
logId: createdLog._id, "_id": createdLog._id,
previousSession: { "loggedType": "LOGIN",
closedWorkTracks: closeResult.closedWorkTracks, "vaId": vaId,
logoutLogId: closeResult.logoutLogId "vaEmail": vaEmail,
"projectId": projectId,
"time": createdLog.time,
} }
});
const response = await authFetch(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) {
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: `${type} Successfully`,
logId: createdLog._id,
SyncSalesforce: result,
previousSession: {
closedWorkTracks: closeResult.closedWorkTracks,
logoutLogId: closeResult.logoutLogId
},
timeUsed: receivedTimeString ? 'received' : 'current',
actualTime: createdLog.time,
// warnings: scheduleCheck.warnings
});
} 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;
}
} }
} }
const createdLog = await LoggedModel.create({ loggedType: type, vaId, vaEmail, projectId }) const createdLog = await LoggedModel.create({
// console.log(createdLog) loggedType: type,
// try { vaId,
// const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`; vaEmail,
// const sfData = { projectId,
// "_id": createdLog._id, time: logTime
// "loggedType": type, });
// "vaId": vaId,
// "vaEmail": vaEmail,
// "projectId": projectId,
// "time": createdLog.time,
// }
// const response = await fetch(salesforceUrl, { try {
// method: 'POST', const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
// headers: { const sfData = {
// 'Content-Type': 'application/json', "_id": createdLog._id,
// // Add any required headers for Salesforce authentication "loggedType": type,
// // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN "vaId": vaId,
// }, "vaEmail": vaEmail,
// body: JSON.stringify(sfData) "projectId": projectId,
// }); "time": createdLog.time,
}
// if (!response.ok) { const response = await authFetch(salesforceUrl, {
// const errorText = await response.text(); method: 'POST',
// throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`); headers: {
// } 'Content-Type': 'application/json',
// Add any required headers for Salesforce authentication
// 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
},
body: JSON.stringify(sfData)
});
// const result = await response.json(); if (!response.ok) {
// // console.log("✅ Successfully synced VA data to Salesforce:", result); const errorText = await response.text();
throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
}
// res.status(201).json({ success: true, message: `${type} Successfully`, logId: createdLog._id, SyncSalesforce: result }); const result = await response.json();
// } catch (error) { // console.log("✅ Successfully synced VA data to Salesforce:", result);
// console.error("Failed to sync with Salesforce:", error.message);
// console.log(error)
// // Create a custom error to distinguish Salesforce errors res.status(201).json({
// const salesforceError = new Error(`Salesforce sync failed: ${error.message}`); success: true,
// salesforceError.isSalesforceError = true; message: `${type} Successfully`,
// throw salesforceError; logId: createdLog._id,
// } SyncSalesforce: result,
timeUsed: receivedTimeString ? 'received' : 'current',
actualTime: createdLog.time,
// warnings: scheduleCheck.warnings
});
} catch (error) {
console.error("Failed to sync with Salesforce:", error.message);
console.log(error)
return res.status(201).send({ success: true, message: `${type} Successfully`, logId: createdLog._id }); // Create a custom error to distinguish Salesforce errors
const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
salesforceError.isSalesforceError = true;
throw salesforceError;
}
} catch (error) { } catch (error) {
console.error("Error in va_project_log_callback:", error);
return res.status(500).send("Internal Server Error"); return res.status(500).send("Internal Server Error");
} }
} }

View File

@ -1,5 +1,6 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { ProjectModel, VAModel, LoggedModel } from "../../../models/vaModel.js"; import { ProjectModel, VAModel, LoggedModel } from "../../../models/vaModel.js";
import getDayNumber from "../../../utils/getDayNumber.js";
function formatHMS(ms) { function formatHMS(ms) {
const totalSeconds = Math.floor(ms / 1000); const totalSeconds = Math.floor(ms / 1000);
@ -72,7 +73,7 @@ async function va_projects_log_list_function(req, res) {
console.log("VA ID:", vaId.toString()); console.log("VA ID:", vaId.toString());
projectList = await ProjectModel.find({ vaAssigned: vaUser._id }); projectList = await ProjectModel.find({ "vaAssigned.vaId": vaUser._id });
} }
if (!projectList.length) { if (!projectList.length) {
@ -110,13 +111,15 @@ async function va_projects_log_list_function(req, res) {
const lastLog = logs[logs.length - 1]; const lastLog = logs[logs.length - 1];
const activityStatus = lastLog?.loggedType === "LOGIN" ? "ACTIVE" : "AWAY"; const activityStatus = lastLog?.loggedType === "LOGIN" ? "ACTIVE" : "AWAY";
const dayNumber = getDayNumber();
finalReport.push({ finalReport.push({
projectId: project._id, projectId: project._id,
projectName: project.name, projectName: project.name,
activityStatus, activityStatus,
totalWorkTime: formatHMS(totalWorkMs), totalWorkTime: formatHMS(totalWorkMs),
totalWorkMs, totalWorkMs,
dedicatedHours: formatHoursToReadable(project?.dedicatedHours), dedicatedHours: formatHoursToReadable(project?.dedicatedHours[dayNumber]) || "0h",
totalLogs: logs.length, totalLogs: logs.length,
status: project?.status status: project?.status
}); });

View File

@ -11,23 +11,36 @@ function formatHMS(ms) {
async function va_projects_report_function(req, res) { async function va_projects_report_function(req, res) {
try { try {
const accessToken = req.headers.token; const sf_token = req.headers["x-auth-token"];
if (!accessToken) { let vaId;
return res.status(401).json({ success: false, message: "Missing token" }); let vaUser;
} let vaEmail;
if(sf_token) {
vaId = req.body.vaId;
vaUser = await VAModel.findById(vaId);
if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found" });
}
vaEmail = vaUser.userDetails.email
} else {
const accessToken = req.headers.token;
if (!accessToken) {
return res.status(401).json({ success: false, message: "Missing token" });
}
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN); const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
if (!decoded || !decoded.email) { if (!decoded || !decoded.email) {
return res.status(401).json({ success: false, message: "Invalid token" }); return res.status(401).json({ success: false, message: "Invalid token" });
} }
const vaEmail = decoded.email; vaEmail = decoded.email;
const vaUser = await VAModel.findOne({ "userDetails.email": vaEmail }); vaUser = await VAModel.findOne({ "userDetails.email": vaEmail });
if (!vaUser) { if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found" }); return res.status(404).json({ success: false, message: "VA not found" });
} }
const vaId = vaUser._id; vaId = vaUser._id;
}
const { projectId, from, to } = req.params; const { projectId, from, to } = req.params;
if (!projectId) { if (!projectId) {
@ -36,9 +49,38 @@ async function va_projects_report_function(req, res) {
// Default: current day range // Default: current day range
const now = new Date(); const now = new Date();
const fromDate = from && from !== "undefined" ? new Date(from) : new Date(now.setHours(0, 0, 0, 0)); let fromDate, toDate;
const toDate = to && to !== "undefined" ? new Date(to) : new Date();
toDate.setHours(23, 59, 59, 999); if (from && from !== "undefined" && from !== "null") {
fromDate = new Date(from);
// If only date string provided (YYYY-MM-DD), set to start of day
if (from.length === 10) {
fromDate.setHours(0, 0, 0, 0);
}
} else {
// Default: 15 days ago
fromDate = new Date();
fromDate.setHours(0, 0, 0, 0);
}
if (to && to !== "undefined" && to !== "null") {
toDate = new Date(to);
// If only date string provided (YYYY-MM-DD), set to end of day
if (to.length === 10) {
toDate.setHours(23, 59, 59, 999);
}
} else {
// Default: today end of day
toDate = new Date();
toDate.setHours(23, 59, 59, 999);
}
if (fromDate > toDate) {
return res.status(400).json({
success: false,
message: "Invalid date range: 'from' date must be before 'to' date"
});
}
// Get all VA login/logout logs for this project in range // Get all VA login/logout logs for this project in range
const logs = await LoggedModel.find({ const logs = await LoggedModel.find({
@ -48,9 +90,36 @@ async function va_projects_report_function(req, res) {
}).sort({ time: 1 }).lean(); }).sort({ time: 1 }).lean();
if (!logs.length) { if (!logs.length) {
return res.json({ success: true, message: "No logs found for this project", data: [] }); return res.json({
success: true,
message: "No logs found for this project in the specified date range",
data: [],
dateRange: {
from: fromDate.toISOString().split('T')[0],
to: toDate.toISOString().split('T')[0],
days: Math.ceil((toDate - fromDate) / (1000 * 60 * 60 * 24))
}
});
} }
// Get all work tracks for this VA and project in the date range
const workTracks = await WorkTrackModel.find({
projectId,
vaId,
$or: [
{ from: { $gte: fromDate, $lte: toDate } },
{ to: { $gte: fromDate, $lte: toDate } },
{
$and: [
{ from: { $lte: fromDate } },
{ to: { $gte: fromDate } }
]
}
]
})
.sort({ from: 1 })
.lean();
// Unique VA IDs // Unique VA IDs
const vaIds = [...new Set(logs.map(log => log.vaId?.toString()))].filter(Boolean); const vaIds = [...new Set(logs.map(log => log.vaId?.toString()))].filter(Boolean);
const vaUsers = await VAModel.find( const vaUsers = await VAModel.find(
@ -60,55 +129,120 @@ async function va_projects_report_function(req, res) {
const projectReport = []; const projectReport = [];
for (const vaId of vaIds) { for (const currentVaId of vaIds) {
const vaLogList = logs.filter(l => l.vaId.toString() === vaId); const vaLogList = logs.filter(l => l.vaId.toString() === currentVaId);
let sessions = []; let sessions = [];
let currentLogin = null; let currentLogin = null;
const vaWorkTracks = workTracks.filter(w => w.vaId.toString() === currentVaId);
// Pair login → logout // Pair login → logout
for (const entry of vaLogList) { for (const entry of vaLogList) {
if (entry.loggedType === "LOGIN") { if (entry.loggedType === "LOGIN") {
currentLogin = entry; currentLogin = entry;
} else if (entry.loggedType === "LOGOUT" && currentLogin) { } else if (entry.loggedType === "LOGOUT" && currentLogin) {
sessions.push({ from: currentLogin.time, to: entry.time }); sessions.push({ from: currentLogin.time, to: entry.time, logId: currentLogin._id });
currentLogin = null; currentLogin = null;
} }
} }
// If still logged in, close at end of day // If still logged in, close at end of day
if (currentLogin) { if (currentLogin) {
sessions.push({ from: currentLogin.time, to: toDate }); sessions.push({ from: currentLogin.time, to: toDate, logId: currentLogin._id });
} }
const vaDetails = vaUsers.find(v => v._id.toString() === vaId); const vaDetails = vaUsers.find(v => v._id.toString() === currentVaId);
let totalWorkMs = 0; let totalWorkMs = 0;
const sessionDetails = []; const sessionDetails = [];
for (const s of sessions) { // for (const s of sessions) {
const workData = await WorkTrackModel.find({ // const workData = await WorkTrackModel.find({
vaId, // vaId,
projectId, // projectId,
from: { $gte: s.from }, // from: { $gte: s.from },
to: { $lte: s.to } // to: { $lte: s.to }
}).sort({ from: 1 }).lean(); // }).sort({ from: 1 }).lean();
const tabs = workData.map(t => ({ // const tabs = workData.map(t => ({
title: t.title, // title: t.title,
appName: t.appName, // appName: t.appName,
from: t.from, // from: t.from,
to: t.to, // to: t.to,
durationMs: t.totalTime, // durationMs: t.totalTime,
durationFormatted: formatHMS(t.totalTime), // durationFormatted: formatHMS(t.totalTime),
})); // }));
const sessionWorkMs = workData.reduce((sum, w) => sum + (w.totalTime || 0), 0); // const sessionWorkMs = workData.reduce((sum, w) => sum + (w.totalTime || 0), 0);
totalWorkMs += sessionWorkMs; // totalWorkMs += sessionWorkMs;
// sessionDetails.push({
// from: s.from,
// to: s.to,
// totalSessionTime: formatHMS(sessionWorkMs),
// tabs,
// });
// }
for (const session of sessions) {
// Find work tracks for this specific session (matching logId)
const sessionWorkTracks = vaWorkTracks.filter(
track => track.logId && track.logId.toString() === session.logId.toString()
);
let sessionTabs = [];
let sessionTotalMs = 0;
// Extract all logs from work tracks for this session
for (const workTrack of sessionWorkTracks) {
if (workTrack.logs && Array.isArray(workTrack.logs)) {
// Process each log entry in the logs array
for (const logEntry of workTrack.logs) {
// Calculate duration for this log entry
const logFrom = new Date(logEntry.from);
const logTo = new Date(logEntry.to || workTrack.to || session.to);
const logDuration = logEntry.totalTime || (logTo - logFrom);
sessionTotalMs += logDuration;
sessionTabs.push({
title: logEntry.title || "",
appName: logEntry.appName || "",
url: logEntry.url || null,
from: logFrom,
to: logTo,
durationMs: logDuration,
durationFormatted: formatHMS(logDuration),
});
}
}
// Also add the main work track if it has separate data
if (workTrack.totalTime && workTrack.totalTime > 0) {
sessionTotalMs += workTrack.totalTime;
// Only add if we don't have logs array data
if (!workTrack.logs || workTrack.logs.length === 0) {
sessionTabs.push({
title: workTrack.title || "",
appName: workTrack.appName || "",
url: workTrack.url || null,
from: workTrack.from,
to: workTrack.to,
durationMs: workTrack.totalTime,
durationFormatted: formatHMS(workTrack.totalTime),
});
}
}
}
totalWorkMs += sessionTotalMs;
sessionDetails.push({ sessionDetails.push({
from: s.from, from: session.from,
to: s.to, to: session.to,
totalSessionTime: formatHMS(sessionWorkMs), totalSessionTime: formatHMS(sessionTotalMs),
tabs, totalSessionMs: sessionTotalMs,
workTracksCount: sessionWorkTracks.length,
tabs: sessionTabs.sort((a, b) => a.from - b.from), // Sort tabs by time
}); });
} }

View File

@ -1,15 +1,24 @@
import mongoose from "mongoose";
import { AdjustedHoursModel } from "../../../models/vaModel.js"; import { AdjustedHoursModel } from "../../../models/vaModel.js";
// Get all adjustment requests (with filtering options) // Get all adjustment requests (with filtering options)
async function get_adjustment_requests_list(req, res) { async function get_adjustment_requests_list(req, res) {
try { try {
const { status, projectId, vaId } = req.params; const { status, projectId, vaId } = req.query;
// Build filter object // Build filter object
const filter = {}; const filter = {};
if (status) filter.status = status; // if (status) filter.status = status;
if (projectId) filter.projectId = projectId; // if (projectId) filter.projectId = projectId;
if (vaId) filter.vaId = vaId; // if (vaId) filter.vaId = vaId;
if (status && status !== "undefined") filter.status = status;
if (projectId && mongoose.Types.ObjectId.isValid(projectId)) {
filter.projectId = new mongoose.Types.ObjectId(projectId);
}
if (vaId && mongoose.Types.ObjectId.isValid(vaId)) {
filter.vaId = new mongoose.Types.ObjectId(vaId);
}
const adjustments = await AdjustedHoursModel.find(filter) const adjustments = await AdjustedHoursModel.find(filter)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })

View File

@ -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 // Create new log entries in LoggedModel
const loginLog = new LoggedModel({ const loginLog = new LoggedModel({
loggedType: "LOGIN", 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 // Update adjustment record as rejected
adjustment.status = "REJECTED"; adjustment.status = "REJECTED";
adjustment.reviewedAt = new Date(); adjustment.reviewedAt = new Date();

View File

@ -1,5 +1,6 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { VAModel, ProjectModel, AdjustedHoursModel } from "../../../models/vaModel.js"; import { VAModel, ProjectModel, AdjustedHoursModel } from "../../../models/vaModel.js";
import authFetch from "../../../utils/authFetch.js";
// Adjust missing hours by creating new records // Adjust missing hours by creating new records
async function va_timesheet_adjustment_function(req, res) { async function va_timesheet_adjustment_function(req, res) {
@ -34,7 +35,7 @@ async function va_timesheet_adjustment_function(req, res) {
// Validate project exists and VA is assigned to it // Validate project exists and VA is assigned to it
const project = await ProjectModel.findOne({ const project = await ProjectModel.findOne({
_id: projectId, _id: projectId,
vaAssigned: vaId "vaAssigned.vaId": vaId
}); });
if (!project) { if (!project) {
@ -59,14 +60,6 @@ async function va_timesheet_adjustment_function(req, res) {
const combineDateTime = (dateObj, timeStr) => { const combineDateTime = (dateObj, timeStr) => {
const [hours, minutes, seconds] = timeStr.split(':').map(Number); const [hours, minutes, seconds] = timeStr.split(':').map(Number);
// // Extract date components from the Date object
// const year = dateObj.getFullYear();
// const month = dateObj.getMonth();
// const day = dateObj.getDate();
// // Create UTC date directly
// return new Date(year, month, day, hours, minutes, seconds);
const combined = new Date(dateObj); const combined = new Date(dateObj);
combined.setHours(hours, minutes, seconds, 0); combined.setHours(hours, minutes, seconds, 0);
@ -120,13 +113,16 @@ async function va_timesheet_adjustment_function(req, res) {
}); });
} }
const processedComment = processHTMLComment(comment);
const adjustmentRequest = new AdjustedHoursModel({ const adjustmentRequest = new AdjustedHoursModel({
vaId, vaId,
projectId, projectId,
date: startOfDay, date: startOfDay,
loginTime: loginDateTime, loginTime: loginDateTime,
logoutTime: logoutDateTime, logoutTime: logoutDateTime,
comment, comment: processedComment.processedHTML,
attachments: processedComment.extractedImages,
status: "PENDING" status: "PENDING"
}); });
@ -143,23 +139,24 @@ async function va_timesheet_adjustment_function(req, res) {
try { try {
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/timesheet-adjustment`; const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/timesheet-adjustment`;
const sfData = { const sfData = {
"id": adjustmentRequest._id, "id": adjustmentRequest._id.toString(),
"va": { "va": {
"id": vaId "id": vaId.toString()
}, },
"project": { "project": {
"id": projectId "id": projectId.toString()
}, },
"date": adjustmentDate.toISOString().split('T')[0], "date": adjustmentDate.toISOString().split('T')[0],
"loginTime": loginDateTime.toISOString(), "loginTime": loginDateTime.toISOString(),
"logoutTime": logoutDateTime.toISOString(), "logoutTime": logoutDateTime.toISOString(),
"comment": comment, "comment": processedComment.plainText !== "" ? processedComment.plainText : "-", // Send plain text to Salesforce
"attachments": processedComment.extractedImages, // Send extracted images info
"status": "PENDING", "status": "PENDING",
"rejectionReason": "", "rejectionReason": "",
"adjustedHours": formatTime(adjustedSeconds) "adjustedHours": formatTime(adjustedSeconds)
} }
const response = await fetch(salesforceUrl, { const response = await authFetch(salesforceUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -171,6 +168,7 @@ async function va_timesheet_adjustment_function(req, res) {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
await AdjustedHoursModel.findByIdAndDelete(adjustmentRequest._id)
throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`); throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
} }
@ -186,7 +184,8 @@ async function va_timesheet_adjustment_function(req, res) {
loginTime: loginDateTime.toISOString(), loginTime: loginDateTime.toISOString(),
logoutTime: logoutDateTime.toISOString(), logoutTime: logoutDateTime.toISOString(),
adjustedHours: formatTime(adjustedSeconds), adjustedHours: formatTime(adjustedSeconds),
comment: comment, comment: processedComment.processedHTML,
attachments: processedComment.extractedImages,
status: "PENDING" status: "PENDING"
}, },
SyncSalesforce: result SyncSalesforce: result
@ -221,4 +220,116 @@ async function va_timesheet_adjustment_function(req, res) {
} }
} }
function processHTMLComment(htmlComment) {
try {
let processedHTML = htmlComment;
const extractedImages = [];
let plainText = '';
// Extract plain text from HTML for Salesforce
plainText = extractPlainTextFromHTML(htmlComment);
// Process base64 images in HTML
const imageProcessingResult = extractAndProcessBase64Images(htmlComment);
processedHTML = imageProcessingResult.processedHTML;
extractedImages.push(...imageProcessingResult.extractedImages);
return {
processedHTML,
plainText,
extractedImages
};
} catch (error) {
console.error("Error processing HTML comment:", error);
// Return original content if processing fails
return {
processedHTML: htmlComment,
plainText: extractPlainTextFromHTML(htmlComment),
extractedImages: []
};
}
}
// Helper function to extract plain text from HTML
function extractPlainTextFromHTML(html) {
try {
// Remove HTML tags and decode HTML entities
let plainText = html
.replace(/<[^>]*>/g, ' ') // Replace tags with spaces
.replace(/\s+/g, ' ') // Collapse multiple spaces
.trim();
// Decode common HTML entities
plainText = plainText
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, '/');
return plainText;
} catch (error) {
console.error("Error extracting plain text from HTML:", error);
return html.replace(/<[^>]*>/g, ' ').trim();
}
}
// Helper function to extract and process base64 images from HTML
function extractAndProcessBase64Images(html) {
const extractedImages = [];
let processedHTML = html;
try {
// Regex to find base64 images in HTML (various formats)
const base64Regex = /<img[^>]+src="data:image\/([a-zA-Z0-9+]+);base64,([^"]+)"[^>]*>/gi;
let match;
let imageIndex = 0;
while ((match = base64Regex.exec(html)) !== null) {
const fullMatch = match[0];
const imageType = match[1]; // png, jpeg, gif, etc.
const base64Data = match[2];
// Create a unique filename
const timestamp = Date.now();
const filename = `adjustment_image_${timestamp}_${imageIndex}.${imageType}`;
// Calculate approximate file size
const size = Math.floor((base64Data.length * 3) / 4); // Base64 size calculation
// Store image information
extractedImages.push({
filename: filename,
mimeType: `image/${imageType}`,
base64Data: base64Data,
size: size,
originalHTML: fullMatch, // Store original img tag for reference
index: imageIndex
});
// Replace base64 with a reference in HTML
const replacementHTML = `<img src="attachment://${filename}" alt="Adjustment evidence ${imageIndex + 1}" data-filename="${filename}" data-image-index="${imageIndex}" class="adjustment-image">`;
processedHTML = processedHTML.replace(fullMatch, replacementHTML);
imageIndex++;
}
return {
processedHTML,
extractedImages
};
} catch (error) {
console.error("Error processing base64 images:", error);
return {
processedHTML: html,
extractedImages: []
};
}
}
export { va_timesheet_adjustment_function }; export { va_timesheet_adjustment_function };

View File

@ -1,26 +1,40 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { VAModel, ProjectModel, LoggedModel } from "../../../models/vaModel.js"; import { VAModel, ProjectModel, LoggedModel } from "../../../models/vaModel.js";
import getDayNumber from "../../../utils/getDayNumber.js";
async function va_timesheet_summary_function(req, res) { async function va_timesheet_summary_function(req, res) {
try { try {
const accessToken = req.headers.token; const sf_token = req.headers["x-auth-token"];
let vaId;
let vaUser;
let vaEmail;
if(sf_token) {
vaId = req.body.vaId;
vaUser = await VAModel.findById(vaId);
if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found" });
}
vaEmail = vaUser.userDetails.email
} else {
const accessToken = req.headers.token;
if (!accessToken) { if (!accessToken) {
return res.status(401).json({ success: false, message: "Missing token" }); return res.status(401).json({ success: false, message: "Missing token" });
}
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
if (!decoded || !decoded.email) {
return res.status(401).json({ success: false, message: "Invalid token" });
}
vaEmail = decoded.email;
vaUser = await VAModel.findOne({ "userDetails.email": vaEmail });
if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found" });
}
vaId = vaUser._id;
} }
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
if (!decoded || !decoded.email) {
return res.status(401).json({ success: false, message: "Invalid token" });
}
const vaEmail = decoded.email;
const vaUser = await VAModel.findOne({ "userDetails.email": vaEmail });
if (!vaUser) {
return res.status(404).json({ success: false, message: "VA not found" });
}
const vaId = vaUser._id;
const { projectId, date } = req.params; const { projectId, date } = req.params;
if (!projectId || !date) { if (!projectId || !date) {
@ -30,7 +44,7 @@ async function va_timesheet_summary_function(req, res) {
// Validate project exists and VA is assigned to it // Validate project exists and VA is assigned to it
const project = await ProjectModel.findOne({ const project = await ProjectModel.findOne({
_id: projectId, _id: projectId,
vaAssigned: vaId "vaAssigned.vaId": vaId
}); });
if (!project) { if (!project) {
@ -149,8 +163,10 @@ async function va_timesheet_summary_function(req, res) {
} }
}; };
const dayNumber = getDayNumber();
// Calculate dedicated hours and pending hours // Calculate dedicated hours and pending hours
const dedicatedHours = project.dedicatedHours || 0; const dedicatedHours = project.dedicatedHours[dayNumber] || 0;
const totalWorkedHours = totalWorkSeconds / 3600; // Convert to hours const totalWorkedHours = totalWorkSeconds / 3600; // Convert to hours
const pendingHours = Math.max(0, dedicatedHours - totalWorkedHours); const pendingHours = Math.max(0, dedicatedHours - totalWorkedHours);

View File

@ -0,0 +1,144 @@
import { VAModel, TipsModel, ClientModel } from "../../../models/vaModel.js";
import authFetch from "../../../utils/authFetch.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 authFetch(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 authFetch(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 };

View File

@ -4,8 +4,10 @@ import logger from "../config/logger.js";
async function authValidator(req, res, next) { async function authValidator(req, res, next) {
try { try {
const sf_token = req.headers.sf_token; const sf_token = req.headers["x-auth-token"];
if (sf_token) { if (sf_token) {
const { vaId } = req.params;
if(vaId) req.body.vaId = vaId;
next(); next();
} else { } else {
const accessToken = req.headers.token; const accessToken = req.headers.token;
@ -48,6 +50,15 @@ async function authValidator(req, res, next) {
async function clientAuthValidator(req, res, next) { async function clientAuthValidator(req, res, next) {
try { try {
const sf_token = req.headers["x-auth-token"];
if (sf_token) {
next();
}
if(sf_token) return;
const accessToken = req.headers.token; const accessToken = req.headers.token;
if (!accessToken) { if (!accessToken) {

View File

@ -46,6 +46,9 @@ const userDetailsSchema = new Schema({
veemAccount: { veemAccount: {
type: String, type: String,
}, },
wiseEmail: {
type: String,
},
phone: { phone: {
type: String, type: String,
}, },
@ -86,19 +89,46 @@ const projectSchema = new Schema({
}, },
vaAssigned: [ vaAssigned: [
{ {
type: Types.ObjectId, _id: false,
ref: "VA", vaId: {
}, type: Types.ObjectId,
ref: "VA",
},
isFlexibleAllowed: {
type: Boolean,
default: true
}
}
], ],
dedicatedHours: { dedicatedHours: {
type: String, type: Array,
default: "0" default: ["0", "0", "0", "0", "0", "0", "0"]
}, },
status: { status: {
type: String, type: String,
enum: ["ACTIVE", "INACTIVE"], enum: ["ACTIVE", "INACTIVE"],
default: "ACTIVE" default: "ACTIVE"
} },
vaWorkingScheduleStart: {
type: String,
default: null
},
vaWorkingScheduleEnd: {
type: String,
default: null
},
isFlexibleAllowed: {
type: Boolean,
default: true
},
vaWorkingDelayAllowedStart: {
type: String,
default: '5m'
},
vaWorkingDelayAllowedEnd: {
type: String,
default: '5m'
},
}); });
/* ---------------------- Client Details Schema ---------------------- */ /* ---------------------- Client Details Schema ---------------------- */
@ -106,6 +136,12 @@ const clientDetailsSchema = new Schema({
name: { name: {
type: String, type: String,
}, },
firstName: {
type: String,
},
lastName: {
type: String,
},
email: { email: {
type: String, type: String,
required: true, required: true,
@ -314,12 +350,6 @@ const vaWorkTrackSchema = new Schema({
type: Types.ObjectId, type: Types.ObjectId,
ref: "TrackLog", ref: "TrackLog",
}, },
title: {
type: String,
},
appName: {
type: String,
},
from: { from: {
type: Date, type: Date,
default: Date.now, default: Date.now,
@ -332,41 +362,45 @@ const vaWorkTrackSchema = new Schema({
type: Number, type: Number,
default: 0, default: 0,
}, },
logs: {
type: Array,
default: [],
}
}, },
{ timestamps: true } { timestamps: true }
) )
const passwordResetSchema = new mongoose.Schema({ const passwordResetSchema = new mongoose.Schema({
email: { email: {
type: String, type: String,
required: true, required: true,
lowercase: true, lowercase: true,
index: true index: true
}, },
token: { token: {
type: String, type: String,
required: true, required: true,
unique: true, unique: true,
index: true index: true
}, },
userType: { userType: {
type: String, type: String,
required: true, required: true,
enum: ['va', 'client'], enum: ['va', 'client'],
index: true index: true
}, },
expiresAt: { expiresAt: {
type: Date, type: Date,
required: true, required: true,
index: true, index: true,
expires: 3600 // TTL index for 1 hour expires: 3600 // TTL index for 1 hour
}, },
used: { used: {
type: Boolean, type: Boolean,
default: false default: false
} }
}, { }, {
timestamps: true timestamps: true
}); });
passwordResetSchema.index({ email: 1, used: 1 }); passwordResetSchema.index({ email: 1, used: 1 });
@ -401,6 +435,14 @@ const adjustedHoursSchema = new Schema({
type: String, type: String,
required: true required: true
}, },
attachments: [{
filename: String,
mimeType: String,
base64Data: String,
size: Number,
originalHTML: String,
index: Number
}],
status: { status: {
type: String, type: String,
enum: ["PENDING", "APPROVED", "REJECTED"], enum: ["PENDING", "APPROVED", "REJECTED"],
@ -450,6 +492,14 @@ const leavesSchema = new Schema({
type: String, type: String,
required: true required: true
}, },
attachments: [{
filename: String,
mimeType: String,
base64Data: String,
size: Number,
originalHTML: String,
index: Number
}],
leaveType: { leaveType: {
type: String, type: String,
enum: ["SICK", "PTO"], enum: ["SICK", "PTO"],
@ -469,6 +519,37 @@ const leavesSchema = new Schema({
} }
}, { timestamps: true }); }, { 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 ---------------------- */ /* ---------------------- Model Exports ---------------------- */
const VAModel = model("VA", vaSchema); const VAModel = model("VA", vaSchema);
const ClientModel = model("Client", clientSchema); const ClientModel = model("Client", clientSchema);
@ -478,5 +559,6 @@ const WorkTrackModel = model("TrackWork", vaWorkTrackSchema);
const PasswordResetModel = model("PasswordReset", passwordResetSchema); const PasswordResetModel = model("PasswordReset", passwordResetSchema);
const AdjustedHoursModel = model("AdjustedHours", adjustedHoursSchema); const AdjustedHoursModel = model("AdjustedHours", adjustedHoursSchema);
const LeavesModel = model("Leaves", leavesSchema); const LeavesModel = model("Leaves", leavesSchema);
const TipsModel = model("Tips", tipsSchema);
export { VAModel, ClientModel, ProjectModel, LoggedModel, WorkTrackModel, PasswordResetModel, AdjustedHoursModel, LeavesModel }; export { VAModel, ClientModel, ProjectModel, LoggedModel, WorkTrackModel, PasswordResetModel, AdjustedHoursModel, LeavesModel, TipsModel };

View File

@ -13,7 +13,7 @@ class VaClientRepository {
async findByEmail(email) { async findByEmail(email) {
try { try {
const response = await ClientModel.findOne({ "userDetails.email": email.toLowerCase() }); const response = await ClientModel.findOne({ "userDetails.email": email });
return response; return response;
} catch (error) { } catch (error) {
throw error; throw error;
@ -31,10 +31,27 @@ class VaClientRepository {
async update(id, data) { async update(id, data) {
try { try {
// Convert nested userDetails object to dot notation
const updateObj = {};
// Handle userDetails fields with dot notation
if (data.userDetails) {
Object.keys(data.userDetails).forEach(key => {
updateObj[`userDetails.${key}`] = data.userDetails[key];
});
}
// Handle other top-level fields if any
Object.keys(data).forEach(key => {
if (key !== 'userDetails') {
updateObj[key] = data[key];
}
});
const response = await ClientModel.findByIdAndUpdate( const response = await ClientModel.findByIdAndUpdate(
id, id,
{ $set: { ...data } }, { $set: updateObj },
{ new: true } { new: true, runValidators: true }
); );
return response; return response;
} catch (error) { } catch (error) {
@ -42,9 +59,53 @@ class VaClientRepository {
} }
} }
async delete(id) { async findProfileByEmail(email) {
try { try {
const response = await ClientModel.findByIdAndDelete(id); const response = await ClientModel.findOne(
{ "userDetails.email": email },
{
// Only specify the fields you want to include
"userDetails.name": 1,
"userDetails.email": 1,
"userDetails.address": 1,
"userDetails.city": 1,
"userDetails.state": 1,
"userDetails.zip": 1,
"userDetails.position": 1
// Don't include any exclusion fields (0) when using inclusion (1)
}
).lean(); // Use lean() for better performance since we don't need mongoose document
return response;
} catch (error) {
throw error;
}
}
// Alternative: If you need to be more explicit about excluding sensitive fields
async findProfileByEmailExplicit(email) {
try {
const response = await ClientModel.findOne(
{ "userDetails.email": email }
).select({
'userDetails.name': 1,
'userDetails.email': 1,
'userDetails.address': 1,
'userDetails.city': 1,
'userDetails.state': 1,
'userDetails.zip': 1,
'userDetails.position': 1
}).lean();
return response;
} catch (error) {
throw error;
}
}
async delete(id, options = {}) {
try {
const response = await ClientModel.findByIdAndDelete(id, options);
return response; return response;
} catch (error) { } catch (error) {
throw error; throw error;

View File

@ -1,7 +1,7 @@
import express from "express"; import express from "express";
const clientRoutes = express.Router(); const clientRoutes = express.Router();
import { va_register_client } from "../../controllers/client/vaClient.controller.js"; import { va_register_client, delete_client, update_client, get_client_dashboard_profile, update_client_password } from "../../controllers/client/vaClient.controller.js";
// import { va_clients_callback } from "../../controllers/va/vaClients/vaClients.controller.js"; // import { va_clients_callback } from "../../controllers/va/vaClients/vaClients.controller.js";
import { va_clients_projects_callback } from "../../controllers/va/vaClients/vaClientProject.controller.js"; import { va_clients_projects_callback } from "../../controllers/va/vaClients/vaClientProject.controller.js";
import { va_login_client } from "../../controllers/client/vaClientLogin.controller.js"; import { va_login_client } from "../../controllers/client/vaClientLogin.controller.js";
@ -11,7 +11,11 @@ import { clientAuthValidator } from "../../middlewares/auth.middleware.js";
import { client_dashboard } from "./../../controllers/client/dashboard/clientDashboard.controller.js"; import { client_dashboard } from "./../../controllers/client/dashboard/clientDashboard.controller.js";
import { client_dashboard_va_log } from "../../controllers/client/dashboard/clientDashboardVaLog.controller.js"; import { client_dashboard_va_log } from "../../controllers/client/dashboard/clientDashboardVaLog.controller.js";
import { client_verify_email } from "../../controllers/client/vaClientEmailVerify.controller.js"; import { client_verify_email } from "../../controllers/client/vaClientEmailVerify.controller.js";
import { client_dashboard_projects } from "../../controllers/client/dashboard/clientDashboardProjects.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";
import { clientDashboardGetVaLogsController } from "../../controllers/client/dashboard/clientDashboardGetVaLogs.controller.js";
import { va_credits_get } from "../../controllers/client/dashboard/clientDashboardCredits.controller.js";
// clients routes // clients routes
clientRoutes.post("/register", va_register_client); // Register a new VA clientRoutes.post("/register", va_register_client); // Register a new VA
@ -19,16 +23,28 @@ clientRoutes.post("/login", va_login_client); // Login Client
clientRoutes.get("/refreshToken", client_refresh_token_callback); // Client Refresh Token clientRoutes.get("/refreshToken", client_refresh_token_callback); // Client Refresh Token
clientRoutes.post("/forgot-password", client_request_password_reset); clientRoutes.post("/forgot-password", client_request_password_reset);
clientRoutes.post("/verify-client-email", client_verify_email); 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("/credits", clientAuthValidator, va_credits_get);
clientRoutes.get("/vaLogsData/:vaId", clientAuthValidator, clientDashboardGetVaLogsController);
// clientRoutes.get("/:clientID?", va_clients_callback); // VA Clients Callback for getting all VA's or specific client by ID (NC) // 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) clientRoutes.get("/:clientId/projects/:projectId", va_clients_projects_callback); // VA Clients Projects Callback (NC)
// dashboard API's // dashboard API's
clientRoutes.get("/dashboard", clientAuthValidator, client_dashboard); // VA Clients Projects Callback clientRoutes.get("/dashboard", clientAuthValidator, client_dashboard); // VA Clients Projects Callback
clientRoutes.post("/dashboard/va-report", clientAuthValidator, client_dashboard_va_log); // VA details clientRoutes.post("/dashboard/va-report", clientAuthValidator, client_dashboard_va_log); // VA details
clientRoutes.get("/dashboard-projects", clientAuthValidator, client_dashboard_projects); // VA Clients Projects Callback clientRoutes.get("/dashboard-projects", clientAuthValidator, client_dashboard_projects); // VA Clients Projects Callback
clientRoutes.get("/profile", clientAuthValidator, get_client_dashboard_profile);
clientRoutes.patch("/profile", clientAuthValidator, update_client_password);
clientRoutes.patch("/:clientId", update_client);
clientRoutes.delete("/:clientId", delete_client);

View File

@ -12,13 +12,15 @@ import { process_reset } from "../../controllers/passwordReset.controllers.js";
import { project_add_sf } from "../../controllers/project/projectAddSf.controller.js" import { project_add_sf } from "../../controllers/project/projectAddSf.controller.js"
import { update_project_sf } from "../../controllers/project/projectUpdateSf.controller.js" import { update_project_sf } from "../../controllers/project/projectUpdateSf.controller.js"
import { authValidator } from "../../middlewares/auth.middleware.js"; import { authValidator } from "../../middlewares/auth.middleware.js";
import { delete_project_sf } from "../../controllers/project/deleteProjectSf.controller.js"
import { createWireGuardConnection, disconnectWireGuardConnection } from '../../vpnHandler/vpn.js';
const v1Routes = express.Router() const v1Routes = express.Router()
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
v1Routes.get("/auth", function(req, res) { v1Routes.get("/auth", function (req, res) {
res.sendFile(path.join(__dirname, '/auth.html')); res.sendFile(path.join(__dirname, '/auth.html'));
}); });
@ -29,12 +31,54 @@ v1Routes.use("/client", clientRoutes);
v1Routes.post("/project", authValidator, project_add_sf); v1Routes.post("/project", authValidator, project_add_sf);
v1Routes.patch("/project/:projectId", authValidator, update_project_sf); v1Routes.patch("/project/:projectId", authValidator, update_project_sf);
v1Routes.delete("/project/:projectId", authValidator, delete_project_sf);
v1Routes.post("/password/reset", process_reset); v1Routes.post("/password/reset", process_reset);
v1Routes.get("/", function(req, res) { v1Routes.get("/", function (req, res) {
res.json({"connected": true}) res.json({ "connected": true })
}) })
v1Routes.post("/vpn/connect", (req, res) => {
try {
const data = createWireGuardConnection();
res.json({
success: true,
message: "VPN connection created",
...data
});
} catch (err) {
res.status(500).json({
success: false,
error: err.message
});
}
});
v1Routes.post("/vpn/disconnect", (req, res) => {
try {
const { ip } = req.body;
if (!ip) {
return res.status(400).json({
success: false,
error: "IP address is required"
});
}
const result = disconnectWireGuardConnection(ip);
if (result && result.success) {
return res.status(200).json(result);
}
return res.status(400).json({success: false, error: result.message});
} catch (err) {
res.status(500).json({
success: false,
error: err.message
});
}
});
// v1Routes.use("/form_submit", authValidator, formRoutes); // v1Routes.use("/form_submit", authValidator, formRoutes);
export default v1Routes; export default v1Routes;

View File

@ -26,8 +26,11 @@ import { va_verify_email } from "../../controllers/va/vaAuth/vaVerifyEmail.contr
import { va_leave_apply_function } from "../../controllers/va/vaLeaves/vaLeavesApply.controller.js"; import { va_leave_apply_function } from "../../controllers/va/vaLeaves/vaLeavesApply.controller.js";
import { va_leave_list_function } from "../../controllers/va/vaLeaves/vaLeaveList.controller.js" import { va_leave_list_function } from "../../controllers/va/vaLeaves/vaLeaveList.controller.js"
import { va_leave_manage_function } from "../../controllers/va/vaLeaves/vaLeaveManage.controller.js"; import { va_leave_manage_function } from "../../controllers/va/vaLeaves/vaLeaveManage.controller.js";
import { va_remove_function } from "../../controllers/va/vaProfile/vaDelete.controller.js";
import { va_payroll_schedule_function } from "../../controllers/va/vaPayroll/vaPayrollSchedule.controller.js";
import { va_payslip_data_function } from "../../controllers/va/vaPayroll/vaPaySlip.controller.js";
vaRoutes.get("/", va_callback); // VA Callback for getting active VA vaRoutes.get("/", va_callback); // VA Callback for getting active VA list
vaRoutes.post("/login", va_login_callback); // VA Callback for login VA vaRoutes.post("/login", va_login_callback); // VA Callback for login VA
vaRoutes.get("/logout", authValidator, va_logout_callback); // VA Callback for login VA vaRoutes.get("/logout", authValidator, va_logout_callback); // VA Callback for login VA
@ -39,14 +42,14 @@ vaRoutes.post("/verify-va-email", va_verify_email);
vaRoutes.get("/profile", authValidator, va_profile_callback); // Get VA Profile vaRoutes.get("/profile", authValidator, va_profile_callback); // Get VA Profile
vaRoutes.patch("/profile", authValidator, va_profile_update_callback); // Update VA Profile vaRoutes.patch("/profile", authValidator, va_profile_update_callback); // Update VA Profile
vaRoutes.delete("/profile/:id", va_remove_function); // VA delete Callback
vaRoutes.get("/:userName", va_username_callback); // VA Username Callback
// timesheet adjustment routes // timesheet adjustment routes
vaRoutes.get("/timesheet/summary/:projectId/:date", authValidator, va_timesheet_summary_function); // Get Timesheet Adjustment for VA on specific project and date vaRoutes.get("/timesheet/summary/:projectId/:date/:vaId?", authValidator, va_timesheet_summary_function); // Get Timesheet Adjustment for VA on specific project and date
vaRoutes.post("/timesheet/adjustment/request", authValidator, va_timesheet_adjustment_function); // Timesheet Adjustment for VA vaRoutes.post("/timesheet/adjustment/request", authValidator, va_timesheet_adjustment_function); // Timesheet Adjustment for VA
// vaRoutes.get("/timesheet/adjustments/list/:status?/:projectId?/:vaId?", get_adjustment_requests_list); // show all adjustment list to approve and reject vaRoutes.get("/timesheet/adjustments/list/:projectId?/:vaId?/:status?", get_adjustment_requests_list); // show all adjustment list to approve and reject
vaRoutes.patch("/timesheet/adjustments/:adjustmentId", va_timesheet_manage_adjustment_request); vaRoutes.patch("/timesheet/adjustments/:adjustmentId", va_timesheet_manage_adjustment_request);
// Leaves routes // Leaves routes
@ -54,8 +57,8 @@ vaRoutes.get("/leaves/all", authValidator, va_leave_list_function); // Get all l
vaRoutes.get("/leaves/status/:status", authValidator, va_leave_list_function); // Leaves Filter by status vaRoutes.get("/leaves/status/:status", authValidator, va_leave_list_function); // Leaves Filter by status
vaRoutes.get("/leaves/type/:leaveType", authValidator, va_leave_list_function); // Filter by leave type vaRoutes.get("/leaves/type/:leaveType", authValidator, va_leave_list_function); // Filter by leave type
vaRoutes.get("/leaves/status/:status/type/:leaveType", authValidator, va_leave_list_function); // leaves Filter vaRoutes.get("/leaves/status/:status/type/:leaveType", authValidator, va_leave_list_function); // leaves Filter
vaRoutes.post("/leave/request", authValidator, va_leave_apply_function); // Timesheet Adjustment for VA vaRoutes.post("/leave/request", authValidator, va_leave_apply_function); // Leave for VA
vaRoutes.patch("/leave/:leaveId", va_leave_manage_function); vaRoutes.patch("/leave/:leaveId", authValidator, va_leave_manage_function);
// timesheet adjustment routes // timesheet adjustment routes
@ -65,7 +68,7 @@ vaRoutes.get("/search", va_filter_search);
// GET Windows Tab Logged Data for VA IN Between Login and LOGOUT // GET Windows Tab Logged Data for VA IN Between Login and LOGOUT
vaRoutes.get("/report/project/:projectId/:from?/:to?", authValidator, va_projects_report_function); vaRoutes.get("/report/project/:projectId/:from?/:to?/:vaId?", authValidator, va_projects_report_function);
// Getting Project List assigned to VA should be ACTIVE and add total logged hours added by VA // Getting Project List assigned to VA should be ACTIVE and add total logged hours added by VA
vaRoutes.get("/list/projects/:projectId?/:from?/:to?", authValidator, va_projects_log_list_function); vaRoutes.get("/list/projects/:projectId?/:from?/:to?", authValidator, va_projects_log_list_function);
@ -73,12 +76,17 @@ vaRoutes.get("/list/projects/:projectId?/:from?/:to?", authValidator, va_project
vaRoutes.post("/log/project", authValidator, va_project_log_callback); // VA Logged IN / Logged Out in project vaRoutes.post("/log/project", authValidator, va_project_log_callback); // VA Logged IN / Logged Out in project
vaRoutes.post("/log/project/track", authValidator, va_log_track_work_callback); // Track Window Tabs Logs Data vaRoutes.post("/log/project/track", authValidator, va_log_track_work_callback); // Track Window Tabs Logs Data
vaRoutes.patch("/log/project/track", authValidator, va_log_track_work_callback); // Update Track Window Tab Closing Time // vaRoutes.patch("/log/project/track", authValidator, va_log_track_work_callback); // Update Track Window Tab Closing Time
vaRoutes.get("/log/workReport/", authValidator, va_log_work_report_callback); // VA Audit Report // All Projects vaRoutes.get("/log/workReport/", authValidator, va_log_work_report_callback); // VA Audit Report // All Projects
vaRoutes.get("/log/project/:projectId/:from?/:to?", authValidator, va_log_callback); // VA / Project Audit Report vaRoutes.get("/log/project/:projectId/:from?/:to?", authValidator, va_log_callback); // VA / Project Audit Report
// VA filter search: supports query params `name`, `email`, `from`, `to` // VA filter search: supports query params `name`, `email`, `from`, `to`
// Payroll routes
vaRoutes.get("/payrollSchedule", authValidator, va_payroll_schedule_function); // VA Payroll Schedule
vaRoutes.get("/paySlip/:vaId", authValidator, va_payslip_data_function); // VA Payslip
vaRoutes.get("/:userName", va_username_callback); // VA Username Callback
export default vaRoutes; export default vaRoutes;

View File

@ -18,6 +18,7 @@ import apiRoutes from './routes/index.js'
import { render_reset_form } from "./controllers/passwordReset.controllers.js"; import { render_reset_form } from "./controllers/passwordReset.controllers.js";
import expressLayouts from "express-ejs-layouts";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
const app = express(); const app = express();
@ -26,19 +27,6 @@ const app = express();
const server = new createServer(app); const server = new createServer(app);
const io = new Server(server); const io = new Server(server);
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
console.log("⚠️ Certificate error:", url);
// Allow for ngrok only
if (url.includes("ngrok")) {
event.preventDefault();
callback(true); // approve the certificate
} else {
callback(false); // reject others
}
});
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
console.log("⚠️ Certificate error:", url); console.log("⚠️ Certificate error:", url);
@ -52,6 +40,11 @@ app.on('certificate-error', (event, webContents, url, error, certificate, callba
}); });
app.set("view engine", "ejs"); app.set("view engine", "ejs");
app.use(expressLayouts);
// DEFAULT layout for all pages
app.set("layout", "layouts/dashboard");
const PORT = process.env.PORT || 8000; const PORT = process.env.PORT || 8000;
app.use(cors({ app.use(cors({
@ -72,7 +65,9 @@ app.use('/api', apiRoutes);
app.get("/download-tracker", (req, res) => { app.get("/download-tracker", (req, res) => {
res.render("download_tracker", { res.render("download_tracker", {
version: "3.2.1" title: 'Download tracker',
version: "3.2.1",
layout: 'layouts/main'
}); });
}); });
@ -87,53 +82,82 @@ app.get("/download-file", (req, res) => {
}); });
app.get('/varegister/:sf_token?', (req, res) => { app.get('/varegister/:sf_token?', (req, res) => {
const { sf_token } = req.params; const { sf_token } = req.params;
try { // No token → invalid request
const decoded = jwt.verify(sf_token, process.env.JWT_SECRET_SF_TOKEN); if (!sf_token) {
return res.status(404).render('not_found', {
title: 'Page Not Found',
layout: 'layouts/error'
});
}
if (decoded) { try {
res.render('va_register', { title: "VA Registration" }); const decoded = jwt.verify(sf_token, process.env.JWT_SECRET_SF_TOKEN);
} else {
res.render('not_found', { title: "VA Registration" });
}
} catch (error) {
res.render('not_found', { title: "VA Registration" });
}
// if(verified) { // Token valid
// res.render('va_register', { title: "VA Registration" }); return res.render('va_register', {
// } else { title: 'VA Registration',
// res.render('not_founf', { title: "VA Registration" }); email: decoded.email,
// } layout: 'layouts/varegisterlayout'
});
} catch (error) {
// Invalid / expired token
return res.status(401).render('not_found', {
title: 'Invalid or Expired Link',
layout: 'layouts/error'
});
}
}); });
app.get('/clientregister/:sf_token?', (req, res) => {
const { sf_token } = req.params;
try {
const decoded = jwt.verify(sf_token, process.env.JWT_SECRET_SF_TOKEN);
if (decoded) { app.get('/clientregister/:sf_token?', (req, res) => {
res.render('va_client_register', { title: "Client Registration" }); const { sf_token } = req.params;
} else {
res.render('not_found', { title: "Client Registration" }); // No token → invalid request
} if (!sf_token) {
} catch (error) { return res.status(404).render('not_found', {
res.render('not_found', { title: "Client Registration" }); title: 'Page Not Found',
} layout: 'layouts/error'
});
}
try {
const decoded = jwt.verify(sf_token, process.env.JWT_SECRET_SF_TOKEN);
// Token valid
return res.render('va_client_register', {
title: 'Client Registration',
email: decoded.email,
layout: 'layouts/vaclientlayout'
});
} catch (error) {
// Invalid / expired token
return res.status(401).render('not_found', {
title: 'Invalid or Expired Link',
layout: 'layouts/error'
});
}
}); });
app.get('/auth/password/reset-form', render_reset_form); app.get('/auth/password/reset-form', render_reset_form);
app.get('/health', (req, res) => { app.get("/client/login", (req, res) => {
res.status(201).send({ status: "Server is healthy" }); // res.render("client_login")
res.render("client/client_login", {
layout: "layouts/auth", // ⬅️ override layout
title: "DocVA | Login"
});
}) })
app.get("/client/login", (req, res) => {
res.render("client_login")
})
app.get("/client/forgot-password", (req, res) => { app.get("/client/forgot-password", (req, res) => {
res.render("forgot_password")
res.render("forgot_password", {
title: "Forgot Password",
layout: "layouts/forgotpasswordlayout"
});
}) })
// Client Dashboard Routes with new file names // Client Dashboard Routes with new file names
@ -141,7 +165,11 @@ app.get('/client/dashboard', async (req, res) => {
try { try {
// const dashboardData = await dashboardService.getClientDashboard(req.user.id); // const dashboardData = await dashboardService.getClientDashboard(req.user.id);
// res.render('client/dashboard-dashboard', dashboardData); // res.render('client/dashboard-dashboard', dashboardData);
res.render('client/dashboard-dashboard'); // res.render('client/dashboard-dashboard');
res.render('client/dashboard-dashboard', {
title: "Client Dashboard",
pageScript: "dashboard.js"
});
} catch (error) { } catch (error) {
console.error('Dashboard error:', error); console.error('Dashboard error:', error);
res.status(500).render('client/dashboard-dashboard', { error: 'Failed to load dashboard' }); res.status(500).render('client/dashboard-dashboard', { error: 'Failed to load dashboard' });
@ -151,7 +179,12 @@ app.get('/client/dashboard', async (req, res) => {
app.get('/client/virtual-assistants', async (req, res) => { app.get('/client/virtual-assistants', async (req, res) => {
try { try {
// const vaData = await virtualAssistantService.getClientVAs(req.user.id); // const vaData = await virtualAssistantService.getClientVAs(req.user.id);
res.render('client/dashboard-virtual-assistants'); // 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); // res.render('client/dashboard-virtual-assistants', vaData);
} catch (error) { } catch (error) {
console.error('Virtual assistants error:', error); console.error('Virtual assistants error:', error);
@ -162,18 +195,78 @@ app.get('/client/virtual-assistants', async (req, res) => {
app.get('/client/projects', async (req, res) => { app.get('/client/projects', async (req, res) => {
try { try {
// const vaData = await virtualAssistantService.getClientVAs(req.user.id); // const vaData = await virtualAssistantService.getClientVAs(req.user.id);
res.render('client/dashboard-projects'); // res.render('client/dashboard-projects');
res.render('client/dashboard-projects', {
title: "Projects",
pageScript:"projects.js"
});
} catch (error) { } catch (error) {
console.error('Projects error:', error); console.error('Projects error:', error);
res.status(500).render('client/dashboard-projects', { error: 'Failed to load virtual assistants' }); res.status(500).render('client/dashboard-projects', { error: 'Failed to load projects' });
} }
}); });
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');
res.render('client/dashboard-manage-va-tips', {
title: 'Manage VA Tips',
pageScript: 'manage-tips.js'
});
} 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/view-logs', async (req, res) => {
try {
// res.render('client/dashboard-va-logs');
res.render('client/dashboard-va-logs', {
title: 'VA Logs',
pageScript: 'va-logs.js'
});
} catch (error) {
console.error('Projects error:', error);
res.status(500).render('client/dashboard-va-logs', { error: 'Failed to load profile' });
}
});
app.get('/client/credits', async (req, res) => {
try {
// res.render('client/dashboard-va-logs');
res.render('client/dashboard-credits', {
title: 'Credits',
pageScript: 'va-credits.js'
});
} catch (error) {
console.error('Projects error:', error);
res.status(500).render('client/dashboard-va-logs', { error: 'Failed to load profile' });
}
});
app.get("/live/:projectId/:vaId", async (req, res) => { app.get("/live/:projectId/:vaId", async (req, res) => {
const { projectId, vaId } = req.params; const { projectId, vaId } = req.params;
if (!projectId || !vaId) { if (!projectId || !vaId) {
return res.status(400).send({ "message": "Page not found" }); return res.status(400).render("not_found", { title: "Page not found" });
} }
let clientId = null; let clientId = null;
@ -184,36 +277,45 @@ app.get("/live/:projectId/:vaId", async (req, res) => {
} }
} catch (err) { } catch (err) {
console.error("Error fetching client for project:", err.message); console.error("Error fetching client for project:", err.message);
return; return res.status(500).render("not_found", { title: "Server Error" });
} }
if (!clientId) return res.status(400).send({ "message": "Page not found" }); if (!clientId) {
return res.status(400).render("not_found", { title: "Page not found" });
res.render("va_live_tracker", { title: "VA Live Tracker", projectId, vaId, clientId })
})
app.get("/live/:projectId/:vaId", async (req, res) => {
const { projectId, vaId } = req.params;
if (!projectId || !vaId) {
return res.status(400).send({ "message": "Page not found" });
} }
let clientId = null; res.render("va_live_tracker", {
try { title: "VA Live Tracker",
const clientRes = await ProjectModel.findById(projectId); projectId,
if (clientRes) { vaId,
clientId = clientRes.clientId.toString(); clientId,
} layout: "layouts/valivetrackerlayout" // ✅ ADD THIS
} catch (err) { });
console.error("Error fetching client for project:", err.message); });
return;
}
if (!clientId) return res.status(400).send({ "message": "Page not found" });
res.render("va_live_tracker", { title: "VA Live Tracker", projectId, vaId, clientId }) // app.get("/live/:projectId/:vaId", async (req, res) => {
}) // const { projectId, vaId } = req.params;
// if (!projectId || !vaId) {
// return res.status(400).send({ "message": "Page not found" });
// }
// let clientId = null;
// try {
// const clientRes = await ProjectModel.findById(projectId);
// if (clientRes) {
// clientId = clientRes.clientId.toString();
// }
// } catch (err) {
// console.error("Error fetching client for project:", err.message);
// return;
// }
// if (!clientId) return res.status(400).send({ "message": "Page not found" });
// res.render("va_live_tracker", { title: "VA Live Tracker", projectId, vaId, clientId })
// })
// Handling Socket // Handling Socket
import { VAModel, ProjectModel, ClientModel } from './models/vaModel.js'; import { VAModel, ProjectModel, ClientModel } from './models/vaModel.js';
@ -290,6 +392,8 @@ io.use(async (socket, next) => {
if (clientUser) { if (clientUser) {
socket.data.role = "client"; socket.data.role = "client";
socket.data.clientId = clientUser._id.toString(); socket.data.clientId = clientUser._id.toString();
socket.data.vaId = vaId.toString();
socket.data.projectId = projectId;
// Get all VA IDs assigned to this client // Get all VA IDs assigned to this client
socket.data.allowedVAs = clientData.vaAssigned.map((va) => va.toString()); socket.data.allowedVAs = clientData.vaAssigned.map((va) => va.toString());
@ -310,7 +414,7 @@ io.on("connection", (socket) => {
console.log("User connected:", socket.id); console.log("User connected:", socket.id);
// Register role and ID (client or VA) // Register role and ID (client or VA)
socket.on("register", async ({ role, id }) => { socket.on("register", async ({ role, id, projectId = null, clientId = null }) => {
if (role === "va") { if (role === "va") {
socket.data.role = "va"; socket.data.role = "va";
socket.data.vaId = id; socket.data.vaId = id;
@ -319,23 +423,35 @@ io.on("connection", (socket) => {
if (role === "client") { if (role === "client") {
socket.data.role = "client"; socket.data.role = "client";
socket.data.clientId = id; socket.data.clientId = clientId;
// 🧩 Get allowed VAs for this client // 🧩 Get allowed VAs for this client
const client = await ClientModel.findById(id) const client = await ClientModel.findById(clientId);
.populate("projectIds") // to access their projects
.populate("vaIds"); // optional if you track directly
// Collect all VA IDs from their projects const a = JSON.stringify(client);
const projectVAIds = new Set(); console.log(a, "client data for allowed VAs");
for (const project of client.projectIds) { if (client.projectIds.length !== 0 && client.projectIds.includes(projectId)) {
const populated = await ProjectModel.findById(project._id).populate("vaAssigned"); const projectVAIds = new Set();
populated.vaAssigned.forEach((va) => projectVAIds.add(va._id.toString()));
const populated = await ProjectModel.findById(projectId).populate("vaAssigned");
populated.vaAssigned.forEach((va) => projectVAIds.add(va.vaId.toString()));
socket.data.allowedVAs = Array.from(projectVAIds);
console.log(`Client ${clientId} can view VAs:`, socket.data.allowedVAs);
io.to(vaSocketRespectToClient.get(`${clientId}-${id}-${projectId}-va`)).emit("startSendAppsDetails", { projId: projectId });
} }
// Store allowed VA IDs in socket memory // Collect all VA IDs from their projects
socket.data.allowedVAs = Array.from(projectVAIds); // const projectVAIds = new Set();
console.log(`Client ${id} can view VAs:`, socket.data.allowedVAs); // for (const project of client.projectIds) {
// const populated = await ProjectModel.findById(project._id).populate("vaAssigned");
// populated.vaAssigned.forEach((va) => projectVAIds.add(va._id.toString()));
// }
// // Store allowed VA IDs in socket memory
// socket.data.allowedVAs = Array.from(projectVAIds);
// console.log(`Client ${clientId} can view VAs:`, socket.data.allowedVAs);
} }
}); });
@ -391,7 +507,7 @@ io.on("connection", (socket) => {
socket.to(vaSocketRespectToClient.get(`${clientId}-${vaId}-${projectId}-va`)).emit("appToCapture", { appTitle }); socket.to(vaSocketRespectToClient.get(`${clientId}-${vaId}-${projectId}-va`)).emit("appToCapture", { appTitle });
}); });
socket.on("getVisibleApps", async ({ token, apps }) => { socket.on("requestVisibleApps", async ({ token }) => {
try { try {
// Verify token // Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET_ACCESS_TOKEN); const decoded = jwt.verify(token, process.env.JWT_SECRET_ACCESS_TOKEN);
@ -407,6 +523,35 @@ io.on("connection", (socket) => {
const vaId = vaUser._id.toString(); const vaId = vaUser._id.toString();
console.log("🟢 Received Apps from VA:", vaId); 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
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();
// Broadcast image only to allowed clients // Broadcast image only to allowed clients
for (let [_, clientSocket] of io.sockets.sockets) { for (let [_, clientSocket] of io.sockets.sockets) {
if ( if (
@ -422,15 +567,77 @@ io.on("connection", (socket) => {
}); });
socket.on('disconnect', () => { // socket.on('disconnect', () => {
console.log('a user Disconnected'); // 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");
io.to(vaSocketRespectToClient.get(`${clientId}-${vaId}-${projectId}-va`)).emit("stopSendAppsDetails", { projId: projectId });
}
}
}); });
}); });
app.use((req, res) => { app.get("/health", (req, res) => {
res.status(404).render("not_found", { return res.status(200).json({ "success": true, "message": "Server is Healthy" })
title: "404 - Page Not Found" })
app.post("/post-test", (req, res) => {
console.log("✅ POST request received at /post-test:", req.body);
return res.status(200).json({ "success": true, "message": "POST request received", "data": req.body })
});
app.get("/tnyk/:sf_token", (req, res) => {
const sf_token = req.params.sf_token;
console.log("Received token:", sf_token);
try {
const decoded = jwt.verify(sf_token, process.env.JWT_SECRET_SF_TOKEN);
if (decoded) {
res.render('client/dashboard-dashboard', { sf_token, title: "Client" });
} else {
res.render('not_found', { title: "Client" });
}
} catch (error) {
res.render('not_found', { title: "Client" });
}
});
app.get("/", (req, res) => {
// res.render("client_login")
res.render("client/client_login", {
layout: "layouts/auth", // ⬅️ override layout
title: "DocVA | Login"
}); });
})
app.get("/forgot-password", (req, res) => {
res.render("forgot_password", {
title: "Forgot Password",
layout: "layouts/forgotpasswordlayout"
});
})
app.use((req, res) => {
res.status(404).render("not_found", {
layout: "layouts/error",
title: "404 - Page Not Found"
});
}); });
server.listen(PORT, () => { server.listen(PORT, () => {

View File

@ -2,11 +2,13 @@ import jwt from "jsonwebtoken";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { VAModel, ClientModel } from "../models/vaModel.js"; import { VAModel, ClientModel } from "../models/vaModel.js";
import logger from "../config/logger.js"; import logger from "../config/logger.js";
import axios from "axios";
import authFetch from "../utils/authFetch.js";
class PasswordResetService { class PasswordResetService {
constructor(passwordResetRepository, emailService) { constructor(passwordResetRepository) {
this.passwordResetRepository = passwordResetRepository; this.passwordResetRepository = passwordResetRepository;
this.emailService = emailService; // this.emailService = emailService;
} }
async requestPasswordReset(email, userType) { async requestPasswordReset(email, userType) {
@ -62,7 +64,7 @@ class PasswordResetService {
logger.info(`Password reset token created for ${userType}: ${email}`); logger.info(`Password reset token created for ${userType}: ${email}`);
return { return {
success: true, success: true,
message: "If the email exists, a reset link has been sent" message: "Email sent successfully. Please check your inbox."
}; };
} catch (error) { } catch (error) {
@ -150,7 +152,7 @@ class PasswordResetService {
await resetRecord.save(); await resetRecord.save();
// Send confirmation email // Send confirmation email
await this.sendPasswordChangedEmail(decoded.email, userType); // await this.sendPasswordChangedEmail(decoded.email, userType);
logger.info(`Password successfully reset for ${userType}: ${decoded.email}`); logger.info(`Password successfully reset for ${userType}: ${decoded.email}`);
return { return {
@ -166,17 +168,21 @@ class PasswordResetService {
async sendResetEmail(email, resetLink, userType) { async sendResetEmail(email, resetLink, userType) {
try { try {
const userTypeText = userType === 'va' ? 'Virtual Assistant' : 'Client'; const userTypeText = userType === 'va' ? 'VA' : 'CLIENT';
const emailData = {
to: email,
subject: `${userTypeText} - Password Reset Request`,
html: this.generateResetEmailTemplate(resetLink, userTypeText),
};
await this.emailService.sendEmail(emailData);
logger.info(`Password reset email sent to ${userType}: ${email}`);
const res = await authFetch(`${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: {
email: email,
url: resetLink,
userType: userTypeText
}
})
});
} catch (error) { } catch (error) {
logger.error(`PasswordReset Service - sendResetEmail error for ${userType}: ${error.message}`); logger.error(`PasswordReset Service - sendResetEmail error for ${userType}: ${error.message}`);
throw new Error("Failed to send reset email"); throw new Error("Failed to send reset email");

View File

@ -1,25 +1,27 @@
import axios from "axios"; import axios from "axios";
import logger from "../config/logger.js"; import logger from "../config/logger.js";
import authFetch from "../utils/authFetch.js";
class SalesforceService { class SalesforceService {
async registerClientInSalesforce(clientData, _id) { async registerClientInSalesforce(clientData, _id) {
try { try {
const url = `${process.env.SF_DOCTRACKER_API_URL}/services/apexrest/docva/api/client`; const url = `${process.env.SF_DOCTRACKER_API_URL}/services/apexrest/docva/api/client`;
// Extract only the required data for Salesforce // Extract only the required data for Salesforce
const salesforcePayload = this.prepareSalesforcePayload(clientData, _id); const salesforcePayload = this.prepareSalesforcePayload(clientData, _id);
logger.info(`SalesforceService - Sending client data to Salesforce: ${clientData.userDetails.email}`); logger.info(`SalesforceService - Sending client data to Salesforce: ${clientData.userDetails.email}`);
const response = await axios.post(url, salesforcePayload, { const response = await authFetch(url, {
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(salesforcePayload),
timeout: 30000, // 30 seconds timeout timeout: 30000, // 30 seconds timeout
}); });
logger.info(`SalesforceService - Client registered successfully in Salesforce: ${clientData.userDetails.email}`); logger.info(`SalesforceService - Client registered successfully in Salesforce: ${clientData.userDetails.email}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
logger.error(`SalesforceService - Error registering client in Salesforce: ${error.message}`); logger.error(`SalesforceService - Error registering client in Salesforce: ${error.message}`);
@ -46,6 +48,8 @@ class SalesforceService {
userDetails: { userDetails: {
_id: _id, _id: _id,
name: userDetails.name, name: userDetails.name,
firstName: userDetails.firstName,
lastName: userDetails.lastName,
email: userDetails.email.toLowerCase(), email: userDetails.email.toLowerCase(),
address: userDetails.address || null, address: userDetails.address || null,
city: userDetails.city || null, city: userDetails.city || null,

View File

@ -1,5 +1,7 @@
import logger from "../config/logger.js"; import logger from "../config/logger.js";
import SalesforceService from "./salesforce.service.js"; import SalesforceService from "./salesforce.service.js";
import { VAModel, ProjectModel, LoggedModel, WorkTrackModel } from "../models/vaModel.js";
import bcrypt from "bcrypt";
class VaClientService { class VaClientService {
constructor(vaClientRepository) { constructor(vaClientRepository) {
@ -16,7 +18,7 @@ class VaClientService {
} }
// Sanitize and prepare data // Sanitize and prepare data
const clientData = this.sanitizeClientData(data); const clientData = await this.sanitizeClientData(data);
const response = await this.clientRepository.create(clientData); const response = await this.clientRepository.create(clientData);
if(response) { if(response) {
@ -53,34 +55,227 @@ class VaClientService {
} }
async updateClient(id, data) { async updateClient(id, data) {
try { try {
const response = await this.clientRepository.update(id, data); // Check if client exists
if (!response) throw new Error("Client not found"); const existingClient = await this.clientRepository.findById(id);
return response; if (!existingClient) {
throw new Error("Client not found");
}
const { userDetails } = data;
if (!userDetails) {
throw new Error("No user details provided for update");
}
// Prepare update data
const updateData = {
userDetails: {}
};
// Define allowed fields for update (excluding password)
const allowedFields = [
"name", "firstName", "lastName", "address", "city", "state", "zip",
"billingName", "billingEmail", "billingAddress", "billingCity",
"billingState", "billingZip", "position", "signatoryName",
"signatoryEmail", "paymentMethod", "bankName", "accountNumber",
"routingNumber", "accountType", "accountHolderName", "cardNumber",
"expiryDate", "securityCode", "cardZip"
];
let hasUpdates = false;
const updatedFields = [];
// Process allowed fields
allowedFields.forEach(field => {
if (userDetails[field] !== undefined && userDetails[field] !== null && userDetails[field] !== '') {
updateData.userDetails[field] = userDetails[field];
hasUpdates = true;
updatedFields.push(field);
}
});
// Handle email update separately (needs uniqueness check)
if (userDetails.email && userDetails.email !== existingClient.userDetails.email) {
// Check if new email already exists
const emailExists = await this.clientRepository.findByEmail(userDetails.email);
if (emailExists && emailExists._id.toString() !== id) {
throw new Error("Email already exists");
}
updateData.userDetails.email = userDetails.email.toLowerCase();
hasUpdates = true;
updatedFields.push('email');
}
// Check if any fields were actually updated
if (!hasUpdates) {
throw new Error("No valid fields to update");
}
// Update the client
const updatedClient = await this.clientRepository.update(id, updateData);
// Notify Salesforce about the update
try {
await this.salesforceService.updateClientInSalesforce(id, data);
logger.info(`Salesforce notified about client update: ${id}`);
} catch (sfError) {
logger.warn(`Salesforce notification failed for client update: ${sfError.message}`);
// Don't throw error - client update should proceed even if Salesforce fails
}
logger.info(`Client updated successfully: ${id}, Fields: ${updatedFields.join(', ')}`);
return {
...updatedClient.toObject(),
updateSummary: {
updatedFields
}
};
} catch (error) { } catch (error) {
logger.error("Client Service - updateClient error: " + error.message); logger.error("Client Service - updateClient error: " + error.message);
throw error; throw error;
} }
} }
async deleteClient(id) { async deleteClient(clientId) {
try { try {
const response = await this.clientRepository.delete(id); // Check if client exists
return response; const existingClient = await this.clientRepository.findById(clientId);
if (!existingClient) {
throw new Error("Client not found");
}
const clientName = existingClient.userDetails?.name;
const vaIds = existingClient.vaIds || [];
const projectIds = existingClient.projectIds || [];
logger.info(`Deleting client: ${clientName} (${clientId})`);
const deletionSummary = {};
// 1. Remove client from all VAs
if (vaIds.length > 0) {
const vaResult = await VAModel.updateMany(
{ _id: { $in: vaIds } },
{ $pull: { clientIds: clientId } }
);
deletionSummary.vasUpdated = vaResult.modifiedCount;
}
// 2. Delete all projects and associated data
if (projectIds.length > 0) {
// Remove project references from VAs
await VAModel.updateMany(
{ projectIds: { $in: projectIds } },
{ $pull: { projectIds: { $in: projectIds } } }
);
// Delete work tracks and logs
await WorkTrackModel.deleteMany({ projectId: { $in: projectIds } });
await LoggedModel.deleteMany({ projectId: { $in: projectIds } });
// Delete projects
const projectResult = await ProjectModel.deleteMany({ _id: { $in: projectIds } });
deletionSummary.projectsDeleted = projectResult.deletedCount;
}
// 3. Delete the client
const deletedClient = await this.clientRepository.delete(clientId);
logger.info(`✅ Successfully deleted client: ${clientName} (${clientId})`);
return {
...deletedClient.toObject(),
deletionSummary
};
} catch (error) { } catch (error) {
logger.error("Client Service - deleteClient error: " + error.message); logger.error("Client Service - deleteClient error: " + error.message);
throw error; throw error;
} }
} }
async getClientProfileByEmail(email) {
try {
const response = await this.clientRepository.findProfileByEmail(email);
return response;
} catch (error) {
logger.error("Client Service - getClientProfileByEmail error: " + error.message);
throw error;
}
}
async updateClientPassword(id, passwordData) {
try {
// Check if client exists
const existingClient = await this.clientRepository.findById(id);
if (!existingClient) {
throw new Error("Client not found");
}
const { currentPassword, newPassword, confirmPassword } = passwordData;
// Validate required fields
if (!currentPassword || !newPassword || !confirmPassword) {
throw new Error("All password fields are required");
}
// Validate password match
if (newPassword !== confirmPassword) {
throw new Error("New passwords do not match");
}
// Verify current password
const isMatch = await bcrypt.compare(currentPassword, existingClient.userDetails.password);
if (!isMatch) {
throw new Error("Current password is incorrect");
}
// Hash new password
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
// Prepare update data
const updateData = {
userDetails: {
password: hashedPassword
}
};
// Update the client password
const updatedClient = await this.clientRepository.update(id, updateData);
logger.info(`Client password updated successfully: ${id}`);
return {
success: true,
message: "Password updated successfully",
clientId: id
};
} catch (error) {
logger.error("Client Service - updateClientPassword error: " + error.message);
throw error;
}
}
// Helper method to sanitize data // Helper method to sanitize data
sanitizeClientData(payload) { async sanitizeClientData(payload) {
const { userDetails } = payload; const { userDetails } = payload;
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(userDetails.password, saltRounds);
return { return {
userDetails: { userDetails: {
firstName: userDetails.firstName,
lastName: userDetails.lastName,
name: userDetails.name, name: userDetails.name,
email: userDetails.email.toLowerCase(), email: userDetails.email.toLowerCase(),
password: hashedPassword,
address: userDetails.address || null, address: userDetails.address || null,
city: userDetails.city || null, city: userDetails.city || null,
state: userDetails.state || null, state: userDetails.state || null,

68
src/utils/authFetch.js Normal file
View File

@ -0,0 +1,68 @@
import fetch from "node-fetch"; // or global fetch in Node 18+
let preOptions = {
token: ''
}
const getAccessToken = async () => {
try {
const response = await fetch(`${process.env.SF_DOCTRACKER_API_URL}services/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.SF_CLIENT_ID,
client_secret: process.env.SF_CLIENT_SECRET,
}),
});
console.log(response)
const access_token = (await response.json()).access_token;
preOptions.token = access_token;
return access_token;
} catch (error) {
console.error('Error fetching access token:', error);
throw error;
}
};
export const authFetch = async (url, options = {}) => {
let isvalid = false;
if (preOptions.token != "" && preOptions.token != null && preOptions.token != undefined) {
isvalid = await fetch(`${process.env.SF_DOCTRACKER_TOKEN_VALIDATION_URL}services/apexrest/docva/health}`, {
method: 'GET',
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preOptions.token}`,
},
});
const a = JSON.stringify(isvalid.body);
console.log(a);
}
if (isvalid.status !== 200) {
await getAccessToken();
}
return fetch(url, {
...options,
headers: {
...(options.headers || {}),
"Content-Type": "application/json",
Authorization: `Bearer ${preOptions.token}`,
},
});
};
export default authFetch;
export { getAccessToken }

View File

@ -0,0 +1,5 @@
const convertToObjectId = (id) => {
return mongoose.Types.ObjectId.isValid(id) ? new mongoose.Types.ObjectId(id) : id;
}
export default convertToObjectId;

View File

@ -0,0 +1,6 @@
function getDayNumber(date = new Date()) {
const today = new Date(date);
return today.getUTCDay(); // getDay() already returns 0-6 for Sunday-Saturday
}
export default getDayNumber;

173
src/vpnHandler/vpn.js Normal file
View File

@ -0,0 +1,173 @@
import fs from "fs";
import { execSync } from "child_process";
const WG_CONF = "/etc/wireguard/wg0.conf";
const PEER_DB = "/etc/wireguard/peers.json";
const CLIENT_DIR = "/etc/wireguard/clients";
const SERVER_PUBLIC_KEY = process.env.SERVER_PUBLIC_KEY || "jG+7H5thEiNjxY1XqI1R9oyMAMTIegLwE3tZkS1Rj2U=";
const SERVER_IP = process.env.SERVER_PUBLIC_IP || "35.95.209.161";
function loadDb() {
try {
return JSON.parse(fs.readFileSync(PEER_DB, "utf8"));
} catch {
const initial = { lastIp: 1, active: {}, free: [] };
fs.writeFileSync(PEER_DB, JSON.stringify(initial, null, 2));
return initial;
}
}
function saveDb(db) {
fs.writeFileSync(PEER_DB, JSON.stringify(db, null, 2));
}
function allocateIp(db) {
if (db.free.length > 0) {
return db.free.shift(); // reuse old IP
}
db.lastIp++;
if (db.lastIp >= 255) db.lastIp = 2;
return `10.0.0.${db.lastIp}`;
}
function generateKeyPair() {
const privateKey = execSync("wg genkey").toString().trim();
const publicKey = execSync(`echo ${privateKey} | wg pubkey`).toString().trim();
return { privateKey, publicKey };
}
// function addPeerToServer(publicKey, ip) {
// const ipLast = ip.split(".")[3];
// const peerEntry = `
// [Peer]
// PublicKey = ${publicKey}
// AllowedIPs = 10.0.0.${ipLast}/32
// `;
// fs.appendFileSync(WG_CONF, peerEntry);
// // apply config instantly
// // execSync(`wg syncconf wg0 <(wg-quick strip wg0)`, { shell: "/bin/bash" });
// execSync(`wg-quick strip wg0 > /tmp/wg0.tmp`);
// execSync(`wg syncconf wg0 /tmp/wg0.tmp`);
// }
function addPeerToServer(publicKey, ip) {
const peerEntry = `
[Peer]
PublicKey = ${publicKey}
AllowedIPs = ${ip}/32
`;
fs.appendFileSync(WG_CONF, peerEntry);
execSync("wg-quick strip wg0 > /tmp/wg0.tmp");
execSync("wg syncconf wg0 /tmp/wg0.tmp");
execSync("rm -f /tmp/wg0.tmp");
execSync(
`wg set wg0 peer ${publicKey} allowed-ips ${ip}/32`,
{ stdio: "inherit" }
);
}
function generateClientConf(privateKey, ip) {
return `
[Interface]
PrivateKey = ${privateKey}
Address = ${ip}/32
DNS = 1.1.1.1
[Peer]
PublicKey = ${SERVER_PUBLIC_KEY}
Endpoint = ${SERVER_IP}:51820
AllowedIPs = 10.0.0.0/24
PersistentKeepalive = 25
`;
}
export function createWireGuardConnection() {
let db = loadDb();
// 1. allocate IP
const ip = allocateIp(db);
// 2. generate keys
const { privateKey, publicKey } = generateKeyPair();
// 3. update server config
addPeerToServer(publicKey, ip);
// 4. update DB
db.active[ip] = publicKey;
saveDb(db);
// 5. prepare client.conf
const clientConf = generateClientConf(privateKey, ip);
// (optional) save it
if (!fs.existsSync(CLIENT_DIR)) fs.mkdirSync(CLIENT_DIR);
fs.writeFileSync(`${CLIENT_DIR}/client-${ip}.conf`, clientConf);
// 6. return to Electron app
return { ip, publicKey, clientConf };
}
// -------- Disconnect VPN PART START -----------
const removePeerFromConf = (publicKey) => {
const conf = fs.readFileSync(WG_CONF, "utf8");
const peerRegex = new RegExp(
`\\n\\[Peer\\][\\s\\S]*?PublicKey = ${publicKey}[\\s\\S]*?(?=\\n\\[Peer\\]|$)`,
"g"
);
const updated = conf.replace(peerRegex, "\n");
fs.writeFileSync(WG_CONF, updated);
}
function syncWireGuard() {
execSync("wg-quick strip wg0 > /tmp/wg0.tmp");
execSync("wg syncconf wg0 /tmp/wg0.tmp");
execSync("rm -f /tmp/wg0.tmp");
}
export function disconnectWireGuardConnection(ip) {
const db = loadDb();
const publicKey = db.active[ip];
if (!db.active[ip]) {
return { success: false, message: "VPN already disconnected" };
}
// 1️⃣ Remove peer from live kernel
execSync(`wg set wg0 peer ${publicKey} remove`);
// 2️⃣ Remove from config file
removePeerFromConf(publicKey);
// 3️⃣ Sync config
syncWireGuard();
// 4️⃣ Update DB (free IP)
delete db.active[ip];
db.free.push(ip);
saveDb(db);
return {
success: true,
message: "VPN disconnected",
ip
};
}

View File

@ -18,7 +18,7 @@
if (userType === 'client') { if (userType === 'client') {
forgotPasswordUrl = '/client/forgot-password'; forgotPasswordUrl = '/client/forgot-password';
loginUrl = '/client/login'; loginUrl = '/';
} }
%> %>
<a href="<%= forgotPasswordUrl %>" class="btn btn-primary" style="margin-bottom:8px;">Request New Reset Link</a> <a href="<%= forgotPasswordUrl %>" class="btn btn-primary" style="margin-bottom:8px;">Request New Reset Link</a>

View File

@ -36,7 +36,7 @@
<% <%
let backToLoginUrl = `${process.env.FRONTEND_URL}/login`; let backToLoginUrl = `${process.env.FRONTEND_URL}/login`;
if (userType === 'client') { if (userType === 'client') {
backToLoginUrl = '/client/login'; backToLoginUrl = '/';
} }
%> %>
<a href="<%= backToLoginUrl %>" class="small-link">Back to Login</a> <a href="<%= backToLoginUrl %>" class="small-link">Back to Login</a>

View File

@ -15,7 +15,7 @@
<% <%
let loginUrl = `${process.env.FRONTEND_URL}/login`; let loginUrl = `${process.env.FRONTEND_URL}/login`;
if (userType === 'client') { if (userType === 'client') {
loginUrl = '/client/login'; loginUrl = '/';
} }
%> %>
<a href="<%= loginUrl %>" class="btn btn-primary">Go to Login</a> <a href="<%= loginUrl %>" class="btn btn-primary">Go to Login</a>

View File

@ -213,7 +213,7 @@
<body> <body>
<div class="logo"> <div class="logo">
<img src="/public/images/logo-white-vertical.svg" alt="DocVA Logo" /> <img id="logo-img" src="/public/images/logo-white-vertical.svg" alt="DocVA Logo" />
</div> </div>
<div class="login-card"> <div class="login-card">
@ -224,7 +224,7 @@
<input type="password" name="password" placeholder="Enter your password *" required /> <input type="password" name="password" placeholder="Enter your password *" required />
<a href="/client/forgot-password" class="forgot">Forgot password?</a> <a href="/client/forgot-password" class="forgot">Forgot password?</a>
<button type="submit" class="btn btn-primary">🔒 Log in using password</button> <button type="submit" class="btn btn-primary">Log in using password</button>
<!-- <div class="divider">OR</div> <!-- <div class="divider">OR</div>
@ -236,6 +236,10 @@
const root = document.documentElement; const root = document.documentElement;
function setTheme(theme) { function setTheme(theme) {
document.getElementById("logo-img").src =
theme === "dark"
? "/public/images/logo-white-vertical.svg"
: "/public/images/logo-colored-vertical.svg";
root.setAttribute("data-theme", theme); root.setAttribute("data-theme", theme);
} }
@ -278,12 +282,12 @@
} }
if (data.success) { if (data.success) {
// 🟢 Save token if(data.userType === "CLIENT") {
localStorage.setItem("knoptpq", data.accessToken); localStorage.setItem("knoptpq", data.accessToken);
// localStorage.setItem("refreshToken", data.refreshToken); window.location.href = "/client/dashboard";
} else {
// 🟢 Redirect window.location.href = "/download-tracker";
window.location.href = "/client/dashboard"; }
} else { } else {
alert("No token received. Please try again."); alert("No token received. Please try again.");
} }

View File

@ -0,0 +1,52 @@
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Total Credits</h3>
<div class="stat-value" id="vaTotalCredits">$0</div>
</div>
<div class="stat-icon blue">
<i class="fas fa-dollar fa-xl"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Available Credits</h3>
<div class="stat-value" id="vaAvailableCredits">$0</div>
</div>
<div class="stat-icon blue">
<i class="fas fa-dollar fa-xl"></i>
</div>
</div>
</div>
</div>
<!-- Virtual Assistants List -->
<div class="content-section">
<div class="table-container">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Status</th>
<th>Date</th>
<th>Description</th>
<th>Amount</th>
</tr>
</thead>
<tbody id="vaCreditTableBody">
<!-- Data will be loaded here -->
</tbody>
</table>
</div>
<div id="vaEmptyCreditState" style="display: none;">
<div class="empty-state">
<i class="fas fa-users"></i>
<h3>No Credit Found</h3>
<p>You haven't received any credit yet.</p>
</div>
</div>
</div>
</div>

View File

@ -1,371 +1,76 @@
<!DOCTYPE html> <!-- Stats Cards -->
<html lang="en" data-theme="light"> <div class="stats-grid">
<head> <div class="stat-card">
<meta charset="UTF-8"> <div class="stat-content">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <div class="stat-info">
<title>DocVA Client | Dashboard</title> <h3>Active VA</h3>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <div class="stat-value" id="activeVaCount">0</div>
<link rel="stylesheet" href="/public/css/dashboard.css"> </div>
</head> <div class="stat-icon blue">
<body class="dashboard-body"> <i class="fas fa-users fa-xl"></i>
<div id="root">
<nav class="dashboard-header">
<div class="header-content">
<div class="header-logo">
<div class="logo-container">
<h2 class="sidebar-title">DocVA Client Portal</h2>
</div>
</div>
<div class="header-nav">
<ul class="nav-list">
<li class="nav-item">
<a href="/client/dashboard" class="nav-button active">
<i class="fas fa-chart-bar"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a href="/client/virtual-assistants" class="nav-button">
<i class="fas fa-users"></i>Virtual Assistants
</a>
</li>
<li class="nav-item">
<a href="/client/projects" class="nav-button">
<i class="fas fa-file-invoice"></i>Projects
</a>
</li>
<!-- <li class="nav-item">
<a href="/client/timesheets" class="nav-button">
<i class="fas fa-clock"></i>Timesheets
</a>
</li>
<li class="nav-item">
<a href="/client/referrals" class="nav-button">
<i class="fas fa-share-alt"></i>Referrals
</a>
</li> -->
</ul>
<div class="user-avatar">TT</div>
<button class="mobile-menu-button">
<i class="fas fa-bars"></i>
</button>
</div>
</div> </div>
</nav>
<div class="dashboard-container">
<aside class="dashboard-sidebar">
<!-- <div class="sidebar-header">
<h2 class="sidebar-title">DocVA Client Portal</h2>
</div> -->
<nav class="sidebar-nav">
<ul class="sidebar-list">
<li class="sidebar-item">
<a href="/client/dashboard" class="sidebar-link active">
<i class="fas fa-chart-bar sidebar-icon"></i>
<span>Dashboard</span>
</a>
</li>
<li class="sidebar-item">
<a href="/client/virtual-assistants" class="sidebar-link">
<i class="fas fa-users sidebar-icon"></i>
<span>Virtual Assistants</span>
</a>
</li>
<li class="sidebar-item">
<a href="/client/projects" class="sidebar-link">
<i class="fas fa-file-invoice sidebar-icon"></i>
<span>Projects</span>
</a>
</li>
<!-- <li class="sidebar-item">
<a href="/client/invoices" class="sidebar-link">
<i class="fas fa-file-invoice sidebar-icon"></i>
<span>Invoices</span>
</a>
</li>
<li class="sidebar-item">
<a href="/client/referrals" class="sidebar-link">
<i class="fas fa-share-alt sidebar-icon"></i>
<span>Referrals</span>
</a>
</li> -->
</ul>
</nav>
</aside>
<main class="dashboard-main">
<div class="dashboard-content">
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Active VA</h3>
<div class="stat-value" id="activeVaCount">0</div>
</div>
<div class="stat-icon blue">
<i class="fas fa-users fa-xl"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Total VA's</h3>
<div class="stat-value" id="totalVaCount">0</div>
</div>
<div class="stat-icon blue">
<i class="fas fa-users fa-xl"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Total Projects</h3>
<div class="stat-value" id="totalProjects">0</div>
</div>
<div class="stat-icon green">
<i class="fas fa-clock fa-xl"></i>
</div>
</div>
</div>
</div>
<!-- Virtual Assistants List -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Virtual Assistants <span class="total-count" id="vaListCount">0 VAs</span></h2>
</div>
<div class="table-container">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Status</th>
<th>Virtual Assistant</th>
<th>Projects Assigned</th>
<th>Active Projects</th>
<th>Worked Time</th>
<th>Action</th>
</tr>
</thead>
<tbody id="vaTableBody">
<!-- Data will be loaded here -->
</tbody>
</table>
</div>
<div id="vaEmptyState" style="display: none;">
<div class="empty-state">
<i class="fas fa-users"></i>
<h3>No Virtual Assistants</h3>
<p>You haven't assigned any virtual assistants yet.</p>
</div>
</div>
</div>
</div>
</div>
</main>
</div> </div>
</div> </div>
<script> <div class="stat-card">
// Theme management <div class="stat-content">
function setTheme(theme) { <div class="stat-info">
document.documentElement.setAttribute('data-theme', theme); <h3>Total VA's</h3>
localStorage.setItem('theme', theme); <div class="stat-value" id="totalVaCount">0</div>
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || getSystemTheme();
setTheme(savedTheme);
// API base URL
const API_BASE = '/api/v1/client';
// Auth token management
function getAuthToken() {
return localStorage.getItem('knoptpq');
}
async function fetchWithAuth(url, options = {}) {
const token = getAuthToken();
if (!token) {
window.location.href = '/client/login';
return;
}
const response = await fetch(url, {
...options,
headers: {
'token': `${token}`,
'Content-Type': 'application/json',
...options.headers,
}
});
if (response.status === 401) {
localStorage.removeItem('knoptpq');
localStorage.removeItem('refreshToken');
window.location.href = '/client/login';
return;
}
return response;
}
// Format time function
function formatTime(seconds) {
if (!seconds) return 'Not working';
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';
}
}
// Create VA card HTML
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 ? `<a href="/live/${va.lastProjectId}/${va.id}" class="view-link" data-va-id="${va.id}" target="_blank">
<i class="fas fa-eye"></i>
View
</a>` : '';
return `
<tr>
<td>
<div class="status-indicator">
<div class="status-dot ${statusClass}"></div>
<span class="status-text ${statusClass}">${statusText}</span>
</div>
</td>
<td>
<div class="va-info">
<div class="va-name">${va.name}</div>
<div class="va-email">${va.email || 'N/A'}</div>
</div>
</td>
<td>
<span class="projects-count">${activeProject}</span>
</td>
<td>
<span class="projects-count">${projectsAssigned} Projects</span>
</td>
<td>
<span class="worked-time">${workedTime}</span>
</td>
<td>
${viewLink}
</td>
</tr>
`;
}
// Load dashboard data
async function loadDashboardData() {
try {
const response = await fetchWithAuth(`${API_BASE}/dashboard`);
if (!response) return;
const result = await response.json();
// console.log(data)
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?.total || [];
if (allVAs.length > 0) {
vaTableBody.innerHTML = allVAs.map(va => createVATableRow(va)).join('');
vaTableBody.style.display = '';
vaEmptyState.style.display = 'none';
// Add event listeners to view links
document.querySelectorAll('.view-link').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const vaId = this.getAttribute('data-va-id');
// You can update this link as needed
window.location.href = `/client/virtual-assistants/${vaId}`;
});
});
} 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 = `
<div class="empty-state">
<i class="fas fa-exclamation-triangle"></i>
<h3>Error</h3>
<p>${message}</p>
</div> </div>
`; <div class="stat-icon blue">
vaEmptyState.style.display = 'block'; <i class="fas fa-users fa-xl"></i>
} </div>
</div>
// Load data when page loads </div>
document.addEventListener('DOMContentLoaded', loadDashboardData);
// Optional: Auto-refresh data every 30 seconds
setInterval(loadDashboardData, 30000);
document.addEventListener('DOMContentLoaded', () => {
var menuToggle = document.querySelector('.mobile-menu-button');
var sidebar = document.querySelector('.dashboard-sidebar');
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('active')
})
})
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Total Projects</h3>
<div class="stat-value" id="totalProjects">0</div>
</div>
<div class="stat-icon green">
<i class="fas fa-clock fa-xl"></i>
</div>
</div>
</div>
</div>
<!-- Virtual Assistants List -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Virtual Assistants <span class="total-count" id="vaListCount">0 VAs</span></h2>
</div>
<div class="table-container">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Status</th>
<th>Virtual Assistant</th>
<th>Active Project</th>
<th>Total Projects</th>
<th>Today Logged Hours</th>
<th>Action</th>
</tr>
</thead>
<tbody id="vaTableBody">
<!-- Data will be loaded here -->
</tbody>
</table>
</div>
<div id="vaEmptyState" style="display: none;">
<div class="empty-state">
<i class="fas fa-users"></i>
<h3>No Virtual Assistants</h3>
<p>You haven't assigned any virtual assistants yet.</p>
</div>
</div>
</div>
</div>
<script>
const tnyk = "<%= typeof sf_token !== 'undefined' ? sf_token : '' %>";
if (tnyk) {
localStorage.setItem('knoptpq', tnyk);
}
</script> </script>
</body>
</html>

View File

@ -0,0 +1,58 @@
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Unbilled Tips</h3>
<div class="stat-value" id="vaUnbilledTips">$0</div>
</div>
<div class="stat-icon blue">
<i class="fas fa-file-invoice fa-xl"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Total Tips</h3>
<div class="stat-value" id="vaTotalTips">$0</div>
</div>
<div class="stat-icon blue">
<i class="fas fa-dollar fa-xl"></i>
</div>
</div>
</div>
</div>
<!-- Virtual Assistants List -->
<div class="content-section">
<div class="section-header" style="justify-content: space-between;align-items: center;">
<a href="/client/virtual-assistants" class="btn btn-secondary">Back</a>
<span class="btn btn-primary" id="tipVaAddBtn">Tip Virtual Assistant</span>
</div>
<div class="table-container">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Void</th>
<th>Status</th>
<th>Virtual Assistant Name</th>
<th>Description</th>
<th>Date Added</th>
<th>Amount</th>
</tr>
</thead>
<tbody id="vaTipTableBody">
<!-- Data will be loaded here -->
</tbody>
</table>
</div>
<div id="vaEmptyTipState" style="display: none;">
<div class="empty-state">
<i class="fas fa-users"></i>
<h3>No Tip Found</h3>
<p>You haven't gave any Tip yet.</p>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,69 @@
<div class="profile-container">
<div class="profile-header">
<h1 class="profile-title">Client Profile</h1>
</div>
<div class="profile-sections">
<!-- Personal Information -->
<div class="profile-section">
<div class="section-header">
<i class="fas fa-user section-icon"></i>
<h2 class="section-title">Personal Information</h2>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">Full Name</label>
<input type="text" class="form-input" id="name" readonly>
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" class="form-input" id="email" readonly>
</div>
<div class="form-group">
<label class="form-label">Position</label>
<input type="text" class="form-input" id="position" readonly>
</div>
</div>
</div>
<!-- Address Information -->
<div class="profile-section">
<div class="section-header">
<i class="fas fa-home section-icon"></i>
<h2 class="section-title">Address Information</h2>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">Address</label>
<input type="text" class="form-input" id="address" readonly>
</div>
<div class="form-group">
<label class="form-label">City</label>
<input type="text" class="form-input" id="city" readonly>
</div>
<div class="form-group">
<label class="form-label">State</label>
<input type="text" class="form-input" id="state" readonly>
</div>
<div class="form-group">
<label class="form-label">ZIP Code</label>
<input type="text" class="form-input" id="zip" readonly>
</div>
</div>
</div>
<!-- Password Change Section -->
<div class="profile-section">
<div class="password-section">
<button class="change-password-btn" id="updateBankDetailsBtn">
<i class="fas fa-credit-card"></i>
Update Bank Details
</button>
<button class="change-password-btn" id="changePasswordBtn">
<i class="fas fa-lock"></i>
Change Password
</button>
</div>
</div>
</div>
</div>

View File

@ -1,378 +1,31 @@
<!DOCTYPE html> <!-- Projects List -->
<html lang="en" data-theme="light"> <div class="content-section">
<head> <div class="section-header">
<meta charset="UTF-8"> <h2 class="section-title">Projects <span class="total-count" id="projectsCount">0
<meta name="viewport" content="width=device-width, initial-scale=1.0"> Projects</span></h2>
<title>DocVA Client | Dashboard</title> </div>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <div class="table-container">
<link rel="stylesheet" href="/public/css/dashboard.css"> <div class="table-wrapper">
</head> <table class="data-table">
<body class="dashboard-body"> <thead>
<div id="root"> <tr>
<nav class="dashboard-header"> <th>Name</th>
<div class="header-content"> <th>Hourly Rate</th>
<div class="header-logo"> <th>Total VA Assigned</th>
<div class="logo-container"> <th>Dedicated Hours</th>
<h2 class="sidebar-title">DocVA Client Portal</h2> </tr>
</div> </thead>
</div> <tbody id="projectsTableBody">
<!-- Data will be loaded here -->
<div class="header-nav"> </tbody>
<ul class="nav-list"> </table>
<li class="nav-item"> </div>
<a href="/client/dashboard" class="nav-button"> <div id="projectsEmptyState" style="display: none;">
<i class="fas fa-chart-bar"></i>Dashboard <div class="empty-state">
</a> <i class="fas fa-briefcase"></i>
</li> <h3>No Projects</h3>
<li class="nav-item"> <p>You don't have any projects yet.</p>
<a href="/client/virtual-assistants" class="nav-button">
<i class="fas fa-users"></i>Virtual Assistants
</a>
</li>
<li class="nav-item">
<a href="/client/projects" class="nav-button active">
<i class="fas fa-file-invoice"></i>Projects
</a>
</li>
<!-- <li class="nav-item">
<a href="/client/timesheets" class="nav-button">
<i class="fas fa-clock"></i>Timesheets
</a>
</li>
<li class="nav-item">
<a href="/client/referrals" class="nav-button">
<i class="fas fa-share-alt"></i>Referrals
</a>
</li> -->
</ul>
<div class="user-avatar">TT</div>
<button class="mobile-menu-button">
<i class="fas fa-bars"></i>
</button>
</div>
</div> </div>
</nav>
<div class="dashboard-container">
<aside class="dashboard-sidebar">
<!-- <div class="sidebar-header">
<h2 class="sidebar-title">DocVA Client Portal</h2>
</div> -->
<nav class="sidebar-nav">
<ul class="sidebar-list">
<li class="sidebar-item">
<a href="/client/dashboard" class="sidebar-link">
<i class="fas fa-chart-bar sidebar-icon"></i>
<span>Dashboard</span>
</a>
</li>
<li class="sidebar-item">
<a href="/client/virtual-assistants" class="sidebar-link">
<i class="fas fa-users sidebar-icon"></i>
<span>Virtual Assistants</span>
</a>
</li>
<li class="sidebar-item">
<a href="/client/projects" class="sidebar-link active">
<i class="fas fa-file-invoice sidebar-icon"></i>
<span>Projects</span>
</a>
</li>
<!-- <li class="sidebar-item">
<a href="/client/invoices" class="sidebar-link">
<i class="fas fa-file-invoice sidebar-icon"></i>
<span>Invoices</span>
</a>
</li>
<li class="sidebar-item">
<a href="/client/referrals" class="sidebar-link">
<i class="fas fa-share-alt sidebar-icon"></i>
<span>Referrals</span>
</a>
</li> -->
</ul>
</nav>
</aside>
<main class="dashboard-main">
<div class="dashboard-content">
<!-- Projects List -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Projects <span class="total-count" id="projectsCount">0 Projects</span></h2>
</div>
<div class="table-container">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Hourly Rate</th>
<th>Total VA Assigned</th>
<th>Dedicated Hours</th>
<th>Status</th>
</tr>
</thead>
<tbody id="projectsTableBody">
<!-- Data will be loaded here -->
</tbody>
</table>
</div>
<div id="projectsEmptyState" style="display: none;">
<div class="empty-state">
<i class="fas fa-briefcase"></i>
<h3>No Projects</h3>
<p>You don't have any projects yet.</p>
</div>
</div>
</div>
</div>
<br><br>
<!-- Virtual Assistants List -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Virtual Assistants <span class="total-count" id="vasCount">0 VAs</span></h2>
</div>
<div class="table-container">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
</tr>
</thead>
<tbody id="vaTableBody">
<!-- Data will be loaded here -->
</tbody>
</table>
</div>
<div id="vaEmptyState" style="display: none;">
<div class="empty-state">
<i class="fas fa-users"></i>
<h3>No Virtual Assistants</h3>
<p>You don't have any virtual assistants assigned yet.</p>
</div>
</div>
</div>
</div>
</div>
</main>
</div> </div>
</div> </div>
<script> </div>
// Theme management
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || getSystemTheme();
setTheme(savedTheme);
// API base URL
const API_BASE = '/api/v1/client';
// Auth token management
function getAuthToken() {
return localStorage.getItem('knoptpq');
}
async function fetchWithAuth(url, options = {}) {
const token = getAuthToken();
if (!token) {
window.location.href = '/client/login';
return;
}
const response = await fetch(url, {
...options,
headers: {
'token': `${token}`,
'Content-Type': 'application/json',
...options.headers,
}
});
if (response.status === 401) {
localStorage.removeItem('knoptpq');
localStorage.removeItem('refreshToken');
window.location.href = '/client/login';
return;
}
return response;
}
// Create Project table row HTML
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 `
<tr>
<td>
<div class="project-info">
<div class="project-name">${project.name || 'Unnamed Project'}</div>
</div>
</td>
<td>
<span class="hourly-rate">${hourlyRate}</span>
</td>
<td>
<span class="va-count">${project.vaAssigned || 0}</span>
</td>
<td>
<span class="dedicated-hours">${dedicatedHours}</span>
</td>
<td>
<div class="status-indicator">
<div class="status-dot ${statusClass}"></div>
<span class="status-text ${statusClass}">${statusText}</span>
</div>
</td>
</tr>
`;
}
// Create VA table row HTML
function createVATableRow(va) {
const statusClass = va.status === 'active' ? 'active' : 'inactive';
const statusText = va.status === 'active' ? 'Active' : 'Inactive';
const fullName = `${va.firstName || ''} ${va.lastName || ''}`.trim() || 'Unknown';
return `
<tr>
<td>
<div class="va-info">
<div class="va-name">${fullName}</div>
</div>
</td>
<td>
<span class="va-email">${va.email || 'N/A'}</span>
</td>
<td>
<div class="status-indicator">
<div class="status-dot ${statusClass}"></div>
<span class="status-text ${statusClass}">${statusText}</span>
</div>
</td>
</tr>
`;
}
// 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`;
document.getElementById('vasCount').textContent = `${counts.totalVAs || 0} VAs`;
// 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';
} else {
projectsTableBody.innerHTML = '';
projectsTableBody.style.display = 'none';
projectsEmptyState.style.display = 'block';
}
// Update VA table
const vaTableBody = document.getElementById('vaTableBody');
const vaEmptyState = document.getElementById('vaEmptyState');
const vas = data.vas || [];
if (vas.length > 0) {
vaTableBody.innerHTML = vas.map(va => createVATableRow(va)).join('');
vaTableBody.style.display = '';
vaEmptyState.style.display = 'none';
} 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 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 = `
<div class="empty-state">
<i class="fas fa-exclamation-triangle"></i>
<h3>Error</h3>
<p>${message}</p>
</div>
`;
projectsEmptyState.style.display = 'block';
vaEmptyState.innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-triangle"></i>
<h3>Error</h3>
<p>${message}</p>
</div>
`;
vaEmptyState.style.display = 'block';
}
// Load data when page loads
document.addEventListener('DOMContentLoaded', loadDashboardData);
// Optional: Auto-refresh data every 30 seconds
setInterval(loadDashboardData, 30000);
document.addEventListener('DOMContentLoaded', () => {
var menuToggle = document.querySelector('.mobile-menu-button');
var sidebar = document.querySelector('.dashboard-sidebar');
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('active')
})
})
</script>
</body>
</html>

View File

@ -0,0 +1,25 @@
<!-- Virtual Assistants List -->
<div class="content-section">
<div class="section-header" style="justify-content: space-between;align-items: center;">
<a href="/client/virtual-assistants" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to VAs
</a>
</div>
<!-- Loading State -->
<div id="loadingState" class="loading-spinner">
<div class="spinner"></div>
<p>Loading VA logs...</p>
</div>
<!-- Empty State -->
<div id="emptyState" class="empty-state" style="display: none;">
<i class="fas fa-clipboard-list"></i>
<h3>No Logs Found</h3>
<p>This virtual assistant hasn't logged any time in the last 15 days.</p>
</div>
<!-- Projects Accordion Container -->
<div id="projectsContainer" style="display: none;"></div>
</div>

View File

@ -1,371 +1,69 @@
<!DOCTYPE html> <!-- Stats Cards -->
<html lang="en" data-theme="light"> <div class="stats-grid">
<head> <div class="stat-card">
<meta charset="UTF-8"> <div class="stat-content">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <div class="stat-info">
<title>DocVA Client | Dashboard</title> <h3>Active VA</h3>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <div class="stat-value" id="activeVaCount">0</div>
<link rel="stylesheet" href="/public/css/dashboard.css"> </div>
</head> <div class="stat-icon blue">
<body class="dashboard-body"> <i class="fas fa-users fa-xl"></i>
<div id="root">
<nav class="dashboard-header">
<div class="header-content">
<div class="header-logo">
<div class="logo-container">
<h2 class="sidebar-title">DocVA Client Portal</h2>
</div>
</div>
<div class="header-nav">
<ul class="nav-list">
<li class="nav-item">
<a href="/client/dashboard" class="nav-button">
<i class="fas fa-chart-bar"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a href="/client/virtual-assistants" class="nav-button active">
<i class="fas fa-users"></i>Virtual Assistants
</a>
</li>
<li class="nav-item">
<a href="/client/projects" class="nav-button">
<i class="fas fa-file-invoice"></i>Projects
</a>
</li>
<!-- <li class="nav-item">
<a href="/client/timesheets" class="nav-button">
<i class="fas fa-clock"></i>Timesheets
</a>
</li>
<li class="nav-item">
<a href="/client/referrals" class="nav-button">
<i class="fas fa-share-alt"></i>Referrals
</a>
</li> -->
</ul>
<div class="user-avatar">TT</div>
<button class="mobile-menu-button">
<i class="fas fa-bars"></i>
</button>
</div>
</div> </div>
</nav>
<div class="dashboard-container">
<aside class="dashboard-sidebar">
<!-- <div class="sidebar-header">
<h2 class="sidebar-title">DocVA Client Portal</h2>
</div> -->
<nav class="sidebar-nav">
<ul class="sidebar-list">
<li class="sidebar-item">
<a href="/client/dashboard" class="sidebar-link">
<i class="fas fa-chart-bar sidebar-icon"></i>
<span>Dashboard</span>
</a>
</li>
<li class="sidebar-item">
<a href="/client/virtual-assistants" class="sidebar-link active">
<i class="fas fa-users sidebar-icon"></i>
<span>Virtual Assistants</span>
</a>
</li>
<li class="sidebar-item">
<a href="/client/projects" class="sidebar-link">
<i class="fas fa-file-invoice sidebar-icon"></i>
<span>Projects</span>
</a>
</li>
<!-- <li class="sidebar-item">
<a href="/client/invoices" class="sidebar-link">
<i class="fas fa-file-invoice sidebar-icon"></i>
<span>Invoices</span>
</a>
</li>
<li class="sidebar-item">
<a href="/client/referrals" class="sidebar-link">
<i class="fas fa-share-alt sidebar-icon"></i>
<span>Referrals</span>
</a>
</li> -->
</ul>
</nav>
</aside>
<main class="dashboard-main">
<div class="dashboard-content">
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Active VA</h3>
<div class="stat-value" id="activeVaCount">0</div>
</div>
<div class="stat-icon blue">
<i class="fas fa-users fa-xl"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Total VA's</h3>
<div class="stat-value" id="totalVaCount">0</div>
</div>
<div class="stat-icon blue">
<i class="fas fa-users fa-xl"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-content">
<div class="stat-info">
<h3>Total Projects</h3>
<div class="stat-value" id="totalProjects">0</div>
</div>
<div class="stat-icon green">
<i class="fas fa-clock fa-xl"></i>
</div>
</div>
</div>
</div>
<!-- Virtual Assistants List -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Virtual Assistants <span class="total-count" id="vaListCount">0 VAs</span></h2>
</div>
<div class="table-container">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Status</th>
<th>Virtual Assistant</th>
<th>Projects Assigned</th>
<th>Active Projects</th>
<th>Worked Time</th>
<th>Action</th>
</tr>
</thead>
<tbody id="vaTableBody">
<!-- Data will be loaded here -->
</tbody>
</table>
</div>
<div id="vaEmptyState" style="display: none;">
<div class="empty-state">
<i class="fas fa-users"></i>
<h3>No Virtual Assistants</h3>
<p>You haven't assigned any virtual assistants yet.</p>
</div>
</div>
</div>
</div>
</div>
</main>
</div> </div>
</div> </div>
<script> <div class="stat-card">
// Theme management <div class="stat-content">
function setTheme(theme) { <div class="stat-info">
document.documentElement.setAttribute('data-theme', theme); <h3>Total VA's</h3>
localStorage.setItem('theme', theme); <div class="stat-value" id="totalVaCount">0</div>
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || getSystemTheme();
setTheme(savedTheme);
// API base URL
const API_BASE = '/api/v1/client';
// Auth token management
function getAuthToken() {
return localStorage.getItem('knoptpq');
}
async function fetchWithAuth(url, options = {}) {
const token = getAuthToken();
if (!token) {
window.location.href = '/client/login';
return;
}
const response = await fetch(url, {
...options,
headers: {
'token': `${token}`,
'Content-Type': 'application/json',
...options.headers,
}
});
if (response.status === 401) {
localStorage.removeItem('knoptpq');
localStorage.removeItem('refreshToken');
window.location.href = '/client/login';
return;
}
return response;
}
// Format time function
function formatTime(seconds) {
if (!seconds) return 'Not working';
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';
}
}
// Create VA card HTML
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 ? `<a href="/live/${va.lastProjectId}/${va.id}" class="view-link" data-va-id="${va.id}" target="_blank">
<i class="fas fa-eye"></i>
View
</a>` : '';
return `
<tr>
<td>
<div class="status-indicator">
<div class="status-dot ${statusClass}"></div>
<span class="status-text ${statusClass}">${statusText}</span>
</div>
</td>
<td>
<div class="va-info">
<div class="va-name">${va.name}</div>
<div class="va-email">${va.email || 'N/A'}</div>
</div>
</td>
<td>
<span class="projects-count">${activeProject}</span>
</td>
<td>
<span class="projects-count">${projectsAssigned} Projects</span>
</td>
<td>
<span class="worked-time">${workedTime}</span>
</td>
<td>
${viewLink}
</td>
</tr>
`;
}
// Load dashboard data
async function loadDashboardData() {
try {
const response = await fetchWithAuth(`${API_BASE}/dashboard`);
if (!response) return;
const result = await response.json();
// console.log(data)
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?.total || [];
if (allVAs.length > 0) {
vaTableBody.innerHTML = allVAs.map(va => createVATableRow(va)).join('');
vaTableBody.style.display = '';
vaEmptyState.style.display = 'none';
// Add event listeners to view links
document.querySelectorAll('.view-link').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const vaId = this.getAttribute('data-va-id');
// You can update this link as needed
window.location.href = `/client/virtual-assistants/${vaId}`;
});
});
} 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 = `
<div class="empty-state">
<i class="fas fa-exclamation-triangle"></i>
<h3>Error</h3>
<p>${message}</p>
</div> </div>
`; <div class="stat-icon blue">
vaEmptyState.style.display = 'block'; <i class="fas fa-users fa-xl"></i>
} </div>
</div>
// Load data when page loads </div>
document.addEventListener('DOMContentLoaded', loadDashboardData); <div class="stat-card">
<div class="stat-content">
// Optional: Auto-refresh data every 30 seconds <div class="stat-info">
setInterval(loadDashboardData, 30000); <h3>Total Projects</h3>
<div class="stat-value" id="totalProjects">0</div>
</div>
document.addEventListener('DOMContentLoaded', () => { <div class="stat-icon green">
var menuToggle = document.querySelector('.mobile-menu-button'); <i class="fas fa-clock fa-xl"></i>
var sidebar = document.querySelector('.dashboard-sidebar'); </div>
menuToggle.addEventListener('click', () => { </div>
sidebar.classList.toggle('active') </div>
}) </div>
}) <!-- Virtual Assistants List -->
<div class="content-section">
<div class="section-header" style="justify-content: space-between;align-items: center;">
</script> <h2 class="section-title">Virtual Assistants <span class="total-count" id="vaListCount">0 VAs</span></h2>
</body> <a href="/client/virtual-assistants/manage-tips" class="btn btn-primary">Manage Tips</a>
</html> </div>
<div class="table-container">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Status</th>
<th>Virtual Assistant</th>
<th>Active Project</th>
<th>Total Projects</th>
<th>Today Logged Hours</th>
<th>Action</th>
</tr>
</thead>
<tbody id="vaTableBody">
<!-- Data will be loaded here -->
</tbody>
</table>
</div>
<div id="vaEmptyState" style="display: none;">
<div class="empty-state">
<i class="fas fa-users"></i>
<h3>No Virtual Assistants</h3>
<p>You haven't assigned any virtual assistants yet.</p>
</div>
</div>
</div>
</div>

View File

@ -139,7 +139,7 @@
<script> <script>
const osButtons = document.querySelectorAll(".os-btn"); const osButtons = document.querySelectorAll(".os-btn");
const downloadBtn = document.getElementById("downloadBtn"); const downloadBtn = document.getElementById("downloadBtn");
const version = "<%= version || '3.2.1' %>"; const version = "<%= version || '1.1.1' %>";
let selectedOS = "windows"; let selectedOS = "windows";
@ -171,7 +171,21 @@
// -------- Download -------- // -------- Download --------
downloadBtn.addEventListener("click", () => { downloadBtn.addEventListener("click", () => {
window.location.href = `/download-file?os=${selectedOS}`; const activeBtn = document.querySelector(".os-btn.active")
const os = activeBtn.dataset.os;
const link = document.createElement("a");
if(os === "mac") {
link.href = `/public/builds/build-1.1.1.1/mac/VA Tracker-1.1.1-universal.dmg`;
link.setAttribute("download", "");
} else if (os === "windows") {
link.href = `/public/builds/build-1.1.1.1/windows/VA Tracker-Setup-1.1.1.exe`;
link.setAttribute("download", "");
}
link.click()
// window.location.href = ``;
}); });
</script> </script>

View File

@ -1,123 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocVA | Reset Password</title>
<style>
:root[data-theme="light"] {
--bg: #ffffff;
--text: #111827;
--card-bg: #ffffff;
--border: #e5e7eb;
--btn-bg: #0a0a8f;
--btn-text: #ffffff;
}
:root[data-theme="dark"] {
--bg: #121212;
--text: #f9fafb;
--card-bg: #1e1e1e;
--border: #334155;
--btn-bg: #3b82f6;
--btn-text: #ffffff;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: "Inter", sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
transition: background-color 0.3s, color 0.3s;
}
.container {
text-align: center;
width: 100%;
max-width: 380px;
}
.logo {
width: 170px;
margin-bottom: 1.5rem;
transition: opacity 0.3s;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: background 0.3s, border-color 0.3s;
}
h2 {
font-size: 1rem;
font-weight: 500;
margin-bottom: 1rem;
}
input {
box-sizing: border-box;
width: 100%;
padding: 0.8rem;
margin-bottom: 1.2rem;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: #111;
font-size: 0.9rem;
transition: border 0.3s;
}
:root[data-theme="dark"] input {
background: #0f172a;
color: #fff;
border: 1px solid #475569;
}
button {
width: 100%;
background: var(--btn-bg);
color: var(--btn-text);
border: none;
padding: 0.8rem;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
transition: opacity 0.3s;
}
button:hover {
opacity: 0.9;
}
a {
display: block;
margin-top: 1rem;
font-size: 0.85rem;
color: var(--text);
text-decoration: none;
opacity: 0.8;
}
a:hover {
opacity: 1;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container"> <div class="container">
<h2>DocTracker</h2> <div class="logo">
<img id="logo-img" src="/public/images/logo-white-vertical.svg" alt="DocVA Logo" />
</div>
<form class="card"> <form class="card">
<h2>Reset your password</h2> <h2>Reset your password</h2>
<input type="email" placeholder="Enter your email address*" required /> <input type="email" placeholder="Enter your email address*" required />
@ -127,6 +12,24 @@
</div> </div>
<script> <script>
const root = document.documentElement;
function setTheme(theme) {
document.getElementById("logo-img").src =
theme === "dark"
? "/public/images/logo-white-vertical.svg"
: "/public/images/logo-colored-vertical.svg";
root.setAttribute("data-theme", theme);
}
function getSystemTheme() {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
setTheme(getSystemTheme());
function validateEmail(email) { function validateEmail(email) {
const emailRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9]*[._-]?[a-zA-Z0-9]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9]*[-]?[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/; const emailRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9]*[._-]?[a-zA-Z0-9]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9]*[-]?[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
return emailRegex.test(email); return emailRegex.test(email);
@ -200,6 +103,3 @@
}); });
}); });
</script> </script>
</body>
</html>

17
views/layouts/auth.ejs Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<title>
<%= title || 'Auth' %>
</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/public/images/favicon.svg" sizes="any">
</head>
<body>
<%- body %>
</body>
</html>

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
<%= title || 'Dashboard' %>
</title>
<link rel="stylesheet" href="/public/css/dashboard.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="icon" href="/public/images/favicon.svg" sizes="any">
</head>
<body class="dashboard-body">
<%- include('../partials/header') %>
<div class="dashboard-container">
<%- include('../partials/sidebar') %>
<main class="dashboard-main">
<div class="dashboard-content">
<%- body %>
</div>
</main>
</div>
<%- include('../partials/va-modal') %>
<%- include('../partials/logout-modal') %>
<%- include('../partials/tip-modal') %>
<%- include('../partials/projects-modal') %>
<%- include('../partials/password-modal') %>
<%- include('../partials/bankDetails-modal') %>
<!-- COMMON SIDEBAR + LOGOUT -->
<script src="/public/js/common/sidebar.js"></script>
<script src="/public/js/common/header.js"></script>
<!-- COMMON -->
<script src="/public/js/common/theme.js"></script>
<script src="/public/js/common/auth.js"></script>
<script src="/public/js/common/logout.js"></script>
<!-- PAGE -->
<% if (typeof pageScript !=='undefined' ) { %>
<script src="/public/js/pages/<%= pageScript %>"></script>
<% } %>
</body>
</html>

103
views/layouts/error.ejs Normal file
View File

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 - Page Not Found</title>
<style>
:root {
--bg: #ffffff;
--text: #222222;
--subtext: #555555;
--button-bg: #6c63ff;
--button-text: #ffffff;
}
/* Dark mode automatic */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f0f;
--text: #ffffff;
--subtext: #bbbbbb;
--button-bg: #6c63ff;
--button-text: #ffffff;
}
}
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-family: Inter, system-ui, sans-serif;
text-align: center;
padding: 20px;
}
.container {
max-width: 420px;
width: 100%;
}
h1 {
font-size: 7rem;
font-weight: 800;
margin: 0;
line-height: 1;
}
p {
font-size: 1.1rem;
margin-top: 10px;
color: var(--subtext);
}
.btn {
margin-top: 30px;
padding: 14px 26px;
background: var(--button-bg);
color: var(--button-text);
text-decoration: none;
font-weight: 600;
border-radius: 12px;
display: inline-block;
transition: 0.2s;
}
.btn:hover {
opacity: 0.85;
transform: translateY(-2px);
}
img {
width: 150px;
margin-bottom: 25px;
}
/* Responsive */
@media (max-width: 480px) {
h1 {
font-size: 4.5rem;
}
img {
width: 120px;
}
}
</style>
</head>
<body>
<%- body %>
</body>
</html>

View File

@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/public/images/favicon.svg" sizes="any">
<title>DocVA | Reset Password</title>
<style>
:root[data-theme="light"] {
--bg: #ffffff;
--text: #111827;
--card-bg: #ffffff;
--border: #e5e7eb;
--btn-bg: #0a0a8f;
--btn-text: #ffffff;
}
:root[data-theme="dark"] {
--bg: #121212;
--text: #f9fafb;
--card-bg: #1e1e1e;
--border: #334155;
--btn-bg: #3b82f6;
--btn-text: #ffffff;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: "Inter", sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
transition: background-color 0.3s, color 0.3s;
}
.container {
text-align: center;
width: 100%;
max-width: 380px;
}
.logo {
width: 170px;
margin-bottom: 1.5rem;
transition: opacity 0.3s;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: background 0.3s, border-color 0.3s;
}
h2 {
font-size: 1rem;
font-weight: 500;
margin-bottom: 1rem;
}
input {
box-sizing: border-box;
width: 100%;
padding: 0.8rem;
margin-bottom: 1.2rem;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: #111;
font-size: 0.9rem;
transition: border 0.3s;
}
:root[data-theme="dark"] input {
background: #0f172a;
color: #fff;
border: 1px solid #475569;
}
button {
width: 100%;
background: var(--btn-bg);
color: var(--btn-text);
border: none;
padding: 0.8rem;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
transition: opacity 0.3s;
}
button:hover {
opacity: 0.9;
}
a {
display: block;
margin-top: 1rem;
font-size: 0.85rem;
color: var(--text);
text-decoration: none;
opacity: 0.8;
}
a:hover {
opacity: 1;
text-decoration: underline;
}
.logo {
text-align: center;
margin-bottom: 20px;
margin-inline: auto;
}
.logo img {
width: 170px;
height: auto;
}
</style>
</head>
<body>
<%- body %>
</body>
</html>

View File

@ -13,5 +13,7 @@
</head> </head>
<body> <body>
<%- body %> <%- body %>
<script src="/public/js/common/theme.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/public/images/favicon.svg" sizes="any">
<title>Client Registration</title>
<link href="/public/css/va-register.css" rel="stylesheet">
</head>
<body>
<%- body %>
</body>
</html>

View File

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Live Screen Feed</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
height: 100vh;
display: flex;
background: #0f0f0f;
color: white;
font-family: "Inter", sans-serif;
overflow: hidden;
}
/* SIDEBAR */
.sidebar {
width: 280px;
background: #181818;
border-right: 1px solid #2b2b2b;
padding: 15px;
overflow-y: auto;
transition: transform 0.35s ease;
z-index: 50;
}
.sidebar h3 {
margin-bottom: 15px;
font-size: 18px;
font-weight: 600;
}
.app {
background: #252525;
border: 1px solid #333;
padding: 12px;
margin-bottom: 10px;
border-radius: 8px;
cursor: pointer;
transition: 0.3s ease;
}
.app:hover {
background: #333;
transform: translateX(3px);
}
.app.active {
background: #6c63ff;
border-color: #6c63ff;
color: white;
box-shadow: 0 0 10px rgba(108, 99, 255, 0.5);
}
/* MAIN CONTENT */
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding: 20px;
overflow: hidden;
}
#selectedAppTitle {
font-size: 20px;
margin-bottom: 15px;
font-weight: 500;
text-align: center;
}
#liveImage {
width: 90%;
max-width: 900px;
border-radius: 10px;
border: 2px solid #444;
object-fit: contain;
background: black;
display: none;
}
.placeholder {
font-size: 16px;
opacity: 0.8;
color: #bbb;
margin-top: 20px;
}
/* MOBILE RESPONSIVE */
.mobile-toggle {
display: none;
position: fixed;
top: 15px;
left: 15px;
background: #6C63FF;
padding: 14px;
border-radius: 8px;
z-index: 9999;
/* VERY HIGH: always visible */
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.mobile-toggle span {
display: block;
width: 26px;
height: 3px;
background: #fff;
margin: 5px 0;
border-radius: 2px;
}
/* Sidebar Hidden on Mobile */
@media (max-width: 780px) {
body {
flex-direction: column;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100%;
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0%);
}
.mobile-toggle {
display: block !important;
}
.content {
padding-top: 60px;
}
/* Dark overlay when sidebar opens */
#overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: none;
z-index: 40;
}
#overlay.show {
display: block;
}
}
</style>
</head>
<body>
<%- body %>
</body>
</html>

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/public/images/favicon.svg" sizes="any">
<title>Virtual Assistant Registration</title>
<link href="/public/css/va-register.css" rel="stylesheet">
<style>
select option:disabled {
display: none;
}
</style>
</head>
<body>
<%- body %>
</body>
</html>

View File

@ -1,105 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 - Page Not Found</title>
<style>
:root {
--bg: #ffffff;
--text: #222222;
--subtext: #555555;
--button-bg: #6c63ff;
--button-text: #ffffff;
}
/* Dark mode automatic */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f0f;
--text: #ffffff;
--subtext: #bbbbbb;
--button-bg: #6c63ff;
--button-text: #ffffff;
}
}
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-family: Inter, system-ui, sans-serif;
text-align: center;
padding: 20px;
}
.container {
max-width: 420px;
width: 100%;
}
h1 {
font-size: 7rem;
font-weight: 800;
margin: 0;
line-height: 1;
}
p {
font-size: 1.1rem;
margin-top: 10px;
color: var(--subtext);
}
.btn {
margin-top: 30px;
padding: 14px 26px;
background: var(--button-bg);
color: var(--button-text);
text-decoration: none;
font-weight: 600;
border-radius: 12px;
display: inline-block;
transition: 0.2s;
}
.btn:hover {
opacity: 0.85;
transform: translateY(-2px);
}
img {
width: 150px;
margin-bottom: 25px;
}
/* Responsive */
@media (max-width: 480px) {
h1 {
font-size: 4.5rem;
}
img {
width: 120px;
}
}
</style>
</head>
<body>
<div class="container"> <div class="container">
<img src="/public/images/logo-white-vertical.svg" alt="404 Icon"> <img src="/public/images/logo-white-vertical.svg" alt="404 Icon">
<h1>404</h1> <h1>404</h1>
<p>Oops! The page you're looking for does not exist.</p> <p>Oops! The page you're looking for does not exist.</p>
<a href="/" class="btn">Go Home</a> <a href="/client/login" class="btn">Go Home</a>
</div> </div>
@ -124,6 +29,3 @@
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme); window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme);
</script> </script>
</body>
</html>

View File

@ -0,0 +1,105 @@
<div id="bankDetailsModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Update Bank Details</h2>
<button class="modal-close" onclick="closeBankDetailsModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<form id="payment-form" class="modal-form">
<div class="payment-option" onclick="selectPaymentOption('card')">
<h4>Credit or Debit Card</h4>
<p>Pay securely with your card</p>
</div>
<div id="card-details" class="payment-details">
<div class="form-group">
<div class="input-container">
<input type="text" id="card-number" class="input-field" placeholder=" " required>
<label for="card-number" class="input-label">Card Number *</label>
</div>
</div>
<div class="row">
<div class="form-group">
<div class="input-container">
<input type="text" id="expiry-date" class="input-field" placeholder=" " required>
<label for="expiry-date" class="input-label">Expiry Date (MM/YY) *</label>
</div>
</div>
<div class="form-group">
<div class="input-container">
<input type="text" id="security-code" class="input-field" placeholder=" " oninput="validateCVVVV(this)" onkeypress="return preventInvalidCVVChars(event)" maxlength="4" required>
<label for="security-code" class="input-label">Security Code *</label>
</div>
<div class="invalid-feedback" id="security-code-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Please enter a valid security code
</div>
</div>
</div>
<div class="form-group">
<div class="input-container">
<input type="text" id="card-zip" class="input-field" placeholder=" " oninput="validateZipCode(this)" onkeypress="return preventNonNumericZip(event)" maxlength="10" required>
<label for="card-zip" class="input-label">ZIP Code *</label>
</div>
<div class="invalid-feedback" id="card-zip-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
ZIP code should contain only numbers
</div>
</div>
</div>
<div class="payment-option" onclick="selectPaymentOption('bank')">
<h4>Bank Account (ACH)</h4>
<p>Direct bank transfer</p>
</div>
<div id="bank-details" class="payment-details">
<div class="form-group">
<div class="input-container">
<input type="text" id="bank-name" class="input-field" placeholder=" " oninput="validateBankName(this)" onkeypress="return preventInvalidBankNameChars(event)" required>
<label for="bank-name" class="input-label">Bank Name *</label>
</div>
<div class="invalid-feedback" id="bank-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Bank name should contain letters and cannot be only numbers or special characters
</div>
</div>
<div class="form-group">
<div class="input-container">
<input type="text" id="account-number" class="input-field" placeholder=" " oninput="validateAccountNumber(this)" onkeypress="return preventInvalidAccountNumberChars(event)" required>
<label for="account-number" class="input-label">Account Number *</label>
</div>
<div class="invalid-feedback" id="account-number-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Please enter a valid account number
</div>
</div>
<div class="form-group">
<div class="input-container">
<input type="text" id="routing-number" class="input-field" placeholder=" " required>
<label for="routing-number" class="input-label">Routing Number *</label>
</div>
</div>
<div class="form-group">
<div class="input-container">
<select id="account-type" class="select-field" required>
<option value="" disable selected style="display: none"></option>
<option value="checking">Checking</option>
<option value="savings">Savings</option>
</select>
<label for="account-type" class="input-label">Account Type *</label>
</div>
</div>
<div class="form-group">
<div class="input-container">
<input type="text" id="account-holder-name" class="input-field" placeholder=" " oninput="validateAccountHolderName(this)" onkeypress="return preventInvalidAccountHolderChars(event)" required>
<label for="account-holder-name" class="input-label">Account Holder Name *</label>
</div>
<div class="invalid-feedback" id="account-holder-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Account holder name should only contain letters and spaces
</div>
</div>
</div>
<div class="modal-actions">
<button type="button" class="cancel-btn" onclick="closeBankDetailsModal()">Cancel</button>
<button type="submit" class="save-btn" id="saveBankDetailsBtn">Update Bank Details</button>
</div>
</form>
</div>
</div>
</div>

64
views/partials/header.ejs Normal file
View File

@ -0,0 +1,64 @@
<nav class="dashboard-header">
<div class="header-content">
<div class="header-logo">
<div class="logo-container">
<h2 class="sidebar-title">DocVA Client Portal</h2>
</div>
</div>
<div class="header-nav">
<ul class="nav-list">
<li class="nav-item">
<a href="/client/dashboard" class="nav-button">
<i class="fas fa-chart-bar"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a href="/client/virtual-assistants" class="nav-button">
<i class="fas fa-users"></i>Virtual Assistants
</a>
</li>
<li class="nav-item">
<a href="/client/projects" class="nav-button">
<i class="fas fa-file-invoice"></i>Projects
</a>
</li>
</ul>
<button class="mobile-menu-button">
<i class="fas fa-bars"></i>
</button>
<!-- Profile Dropdown -->
<div class="header-profile">
<div class="profile-trigger">
<i class="fas fa-user"></i>
</div>
<div class="profile-menu">
<a href="/client/profile">
<i class="fas fa-user"></i>
Profile
</a>
<!-- <a href="/client/account-settings">
<i class="fas fa-cog"></i>
Account Settings
</a> -->
<!-- <a href="/client/billing">
<i class="fas fa-credit-card"></i>
Billing & Payments
</a> -->
<div class="menu-divider"></div>
<button class="logout-btn" onclick="openLogoutModal()">
<i class="fas fa-sign-out-alt"></i>
Logout
</button>
</div>
</div>
</div>
</div>
</nav>

View File

@ -0,0 +1,21 @@
<div id="logoutModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Confirm Logout</h2>
<button class="modal-close" onclick="closeLogoutModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="logout-modal-body">
<div class="logout-icon">
<i class="fas fa-sign-out-alt"></i>
</div>
<h3 class="logout-title">Are you sure you want to logout?</h3>
<p class="logout-message">You will be logged out of your account and redirected to the login page.</p>
<div class="logout-actions">
<button class="logout-cancel-btn" onclick="closeLogoutModal()">Cancel</button>
<button class="logout-confirm-btn" id="confirmLogoutBtn">Yes, Logout</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,33 @@
<div id="passwordModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Change Password</h2>
<button class="modal-close" onclick="closePasswordModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<form class="modal-form" id="passwordForm">
<div class="form-group">
<label class="form-label">Current Password</label>
<input type="password" class="form-input" id="currentPassword" required>
<div class="error-message" id="currentPasswordError"></div>
</div>
<div class="form-group">
<label class="form-label">New Password</label>
<input type="password" class="form-input" id="newPassword" required>
<div class="error-message" id="newPasswordError"></div>
</div>
<div class="form-group">
<label class="form-label">Confirm New Password</label>
<input type="password" class="form-input" id="confirmPassword" required>
<div class="error-message" id="confirmPasswordError"></div>
</div>
<div class="modal-actions">
<button type="button" class="cancel-btn" onclick="closePasswordModal()">Cancel</button>
<button type="submit" class="save-btn" id="savePasswordBtn">Change Password</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,39 @@
<div id="projectModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header" style="display: flex;align-items: center;justify-content: space-between;">
<h2 class="modal-title" id="modalProjectName">Project Details</h2>
<button class="modal-close" id="modalClose">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="va-details-section">
<h3 class="va-section-title">
<i class="fas fa-users"></i>
Virtual Assistants
<span class="va-count-badge" id="vaCountBadge">0</span>
</h3>
<div class="va-table-container">
<table class="va-table">
<thead>
<tr>
<th>Virtual Assistant</th>
<th>Status</th>
<th>Activity</th>
</tr>
</thead>
<tbody id="modalVATableBody">
<!-- VA details will be loaded here -->
</tbody>
</table>
<div id="modalNoVAs" class="no-vas-message" style="display: none;">
<i class="fas fa-user-friends"></i>
<h4>No Virtual Assistants</h4>
<p>No VAs are currently assigned to this project.</p>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,53 @@
<aside class="dashboard-sidebar" id="dashboardSidebar">
<nav class="sidebar-nav">
<ul class="sidebar-list">
<li>
<a href="/client/profile" class="sidebar-link">
<i class="fas fa-user"></i>
<span>Profile</span>
</a>
</li>
<li>
<a href="/client/dashboard" class="sidebar-link">
<i class="fas fa-chart-bar"></i>
<span>Dashboard</span>
</a>
</li>
<li>
<a href="/client/virtual-assistants" class="sidebar-link">
<i class="fas fa-users"></i>
<span>Virtual Assistants</span>
</a>
</li>
<li>
<a href="/client/projects" class="sidebar-link">
<i class="fas fa-file-invoice"></i>
<span>Projects</span>
</a>
</li>
<li>
<a href="/client/credits" class="sidebar-link">
<i class="fas fa-wallet"></i>
<span>Credits</span>
</a>
</li>
<li>
<a href="javascript:void(0)" id="logoutButton" class="sidebar-link logout">
<i class="fas fa-sign-out-alt"></i>
<span>Logout</span>
</a>
</li>
</ul>
</nav>
</aside>

View File

@ -0,0 +1,36 @@
<div id="tipModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Tip Virtual Assistant</h2>
<button class="modal-close" onclick="closeTipModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<form class="modal-form" id="tipForm">
<div class="form-group">
<label class="form-label">Select Virtual Assistant</label>
<select name="vaForTip" class="form-input" id="vaForTip" required>
<option value="">-- Select Virtual Assistant --</option>
<!-- Options will be populated dynamically -->
</select>
<div class="error-message" id="vaForTipError"></div>
</div>
<div class="form-group">
<label class="form-label">Tip Amount</label>
<input type="text" class="form-input" id="vaTipAmount" required>
<div class="error-message" id="vaTipAmountError"></div>
</div>
<div class="form-group">
<label class="form-label">Tip Description</label>
<textarea required="true" name="vaTipDescription" class="form-input" id="vaTipDescription"></textarea>
<div class="error-message" id="vaTipDescriptionError"></div>
</div>
<div class="modal-actions">
<button type="button" class="cancel-btn" onclick="closeTipModal()">Cancel</button>
<button type="submit" class="save-btn" id="submitTipBtn">Submit Tip</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,26 @@
<div id="vaModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalVAName">VA Details</h2>
<button class="modal-close" onclick="closeVAModal()">✖</button>
</div>
<div class="modal-body">
<table class="va-table">
<thead>
<tr>
<th>Project</th>
<th>Rate</th>
<th>Time</th>
<th>Status</th>
</tr>
</thead>
<tbody id="modalProjectsTableBody"></tbody>
</table>
<div id="modalNoProjects" class="empty-state" style="display:none">
No Projects Assigned
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,49 @@
<div id="vaModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header" style="display: flex;align-items: center;justify-content: space-between;">
<h2 class="modal-title" id="modalVAName">VA Details</h2>
<button class="modal-close" onclick="closeVAModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<!-- Projects Section -->
<div class="va-details-section">
<h3 class="va-section-title">
<i class="fas fa-briefcase"></i>
Projects
<span class="va-count-badge" id="projectsCountBadge">0</span>
</h3>
<div class="va-table-container">
<table class="va-table">
<thead>
<tr>
<th>Project Name</th>
<th>Hourly Rate</th>
<th>Worked Time</th>
<th>Status</th>
</tr>
</thead>
<tbody id="modalProjectsTableBody">
<!-- Project details will be loaded here -->
</tbody>
</table>
<div id="modalNoProjects" class="no-vas-message" style="display: none;">
<i class="fas fa-briefcase"></i>
<h4>No Projects</h4>
<p>This VA is not assigned to any projects.</p>
</div>
</div>
</div>
<!-- Action Button -->
<div class="modal-actions" id="modalActions" style="display: none;">
<a href="#" class="view-session-btn" id="viewSessionBtn" target="_blank">
<i class="fas fa-eye"></i>
View Live Session
</a>
</div>
</div>
</div>
</div>

View File

@ -1,16 +1,4 @@
<!DOCTYPE html> <div class="container">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Client Registration</title>
<link href="/public/css/va-register.css" rel="stylesheet">
</head>
<body>
<div class="container">
<!-- Sidebar for desktop --> <!-- Sidebar for desktop -->
<div class="sidebar"> <div class="sidebar">
<div class="logo"> <div class="logo">
@ -21,7 +9,7 @@
<nav class="progress-steps"> <nav class="progress-steps">
<div class="step active" data-step="1" onclick="goToStep(1)"> <div class="step active" data-step="1" onclick="goToStep(1)">
<div class="step-number">1</div> <div class="step-number">1</div>
<!-- <div class="step-number">✓</div> --> <!-- <div class="step-number">✓</div> -->
<div class="step-text">Create Your Account</div> <div class="step-text">Create Your Account</div>
<div class="step-connector"></div> <div class="step-connector"></div>
</div> </div>
@ -59,7 +47,7 @@
<ol> <ol>
<li class="step active" data-step="1" onclick="goToStep(1)"> <li class="step active" data-step="1" onclick="goToStep(1)">
<div class="step-number">1</div> <div class="step-number">1</div>
<!-- <div class="step-number">✓</div> --> <!-- <div class="step-number">✓</div> -->
<div class="step-text">Create Account</div> <div class="step-text">Create Account</div>
</li> </li>
<li class="step" data-step="2" onclick="goToStep(2)"> <li class="step" data-step="2" onclick="goToStep(2)">
@ -98,19 +86,47 @@
<form id="account-form"> <form id="account-form">
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="company-name" class="input-field" placeholder=" " oninput="validateCompanyName(this)" required> <input type="text" id="first-name" class="input-field" placeholder=" " oninput="validateFirstName(this)" required>
<label for="company-name" class="input-label">Company / Client Name *</label> <label for="first-name" class="input-label">First Name *</label>
</div> </div>
<div class="invalid-feedback" id="company-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="first-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Company name should only contain letters, numbers, spaces, hyphens, apostrophes, ampersands, and periods First name should only contain letters, numbers, spaces, hyphens, apostrophes, ampersands, and periods
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="email" id="email" class="input-field" placeholder=" " required> <input type="text" id="last-name" class="input-field" placeholder=" " oninput="validateLastName(this)" required>
<label for="last-name" class="input-label">Last Name *</label>
</div>
<div class="invalid-feedback" id="last-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Last name should only contain letters, numbers, spaces, hyphens, apostrophes, ampersands, and periods
</div>
</div>
<div class="form-group">
<div class="input-container">
<input type="email" id="email" class="input-field" placeholder=" " value="<%= email %>"
readonly disabled required>
<label for="email" class="input-label">Email *</label> <label for="email" class="input-label">Email *</label>
</div> </div>
</div> </div>
<div class="form-group">
<div class="input-container">
<input type="password" id="password" class="input-field" placeholder=" " required autocomplete="new-password">
<label for="password" class="input-label">Create Password *</label>
</div>
</div>
<div class="form-group">
<div class="input-container">
<input type="password" id="confirmPassword" class="input-field" placeholder=" " required autocomplete="new-password">
<label for="confirmPassword" class="input-label">Confirm Password *</label>
</div>
<div class="invalid-feedback" id="password-match-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">Passwords do not match</div>
<div class="password-actions">
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()">Show Password</button>
<button type="button" class="password-generate" onclick="generatePassword()">Generate Strong Password</button>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<div></div> <!-- Empty div for spacing --> <div></div> <!-- Empty div for spacing -->
<button type="button" class="btn btn-primary" onclick="handleNextStep(2)">Continue</button> <button type="button" class="btn btn-primary" onclick="handleNextStep(2)">Continue</button>
@ -125,6 +141,15 @@
<div class="error-message" id="step-2-error-message"></div> <div class="error-message" id="step-2-error-message"></div>
</div> </div>
<form id="business-form"> <form id="business-form">
<div class="form-group">
<div class="input-container">
<input type="text" id="company-name" class="input-field" placeholder=" " oninput="validateCompanyName(this)" required>
<label for="company-name" class="input-label">Company Name *</label>
</div>
<div class="invalid-feedback" id="company-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Company name should only contain letters, numbers, spaces, hyphens, apostrophes, ampersands, and periods
</div>
</div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="address" class="input-field" placeholder=" " required> <input type="text" id="address" class="input-field" placeholder=" " required>
@ -133,18 +158,21 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="city" class="input-field" placeholder=" " oninput="validateCity(this)" onkeypress="return preventInvalidCityChars(event)" required> <input type="text" id="city" class="input-field" placeholder=" "
oninput="validateCity(this)" onkeypress="return preventInvalidCityChars(event)"
required>
<label for="city" class="input-label">City *</label> <label for="city" class="input-label">City *</label>
</div> </div>
<div class="invalid-feedback" id="city-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="city-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
City name should contain letters and cannot be only numbers or special characters City name should contain letters and cannot be only numbers or special characters
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<!-- <input type="text" id="state" class="input-field" placeholder=" " required> --> <!-- <input type="text" id="state" class="input-field" placeholder=" " required> -->
<select id="state" class="select-field" required> <select id="state" class="select-field" required>
<option value=""></option> <option value="" disable selected style="display: none"></option>
<!-- States will be populated by JavaScript --> <!-- States will be populated by JavaScript -->
</select> </select>
<label for="state" class="input-label">State *</label> <label for="state" class="input-label">State *</label>
@ -153,11 +181,14 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="zip" class="input-field" placeholder=" " oninput="validateZipCode(this)" onkeypress="return preventNonNumericZip(event)" maxlength="10" required> <input type="text" id="zip" class="input-field" placeholder=" "
oninput="validateZipCode(this)" onkeypress="return preventNonNumericZip(event)"
maxlength="10" required>
<label for="zip" class="input-label">ZIP *</label> <label for="zip" class="input-label">ZIP *</label>
</div> </div>
<div class="invalid-feedback" id="zip-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="zip-error"
ZIP code should contain only numbers style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Invalid Zip code
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
@ -192,18 +223,21 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="billing-city" class="input-field" placeholder=" " oninput="validateCity(this)" onkeypress="return preventInvalidCityChars(event)" required> <input type="text" id="billing-city" class="input-field" placeholder=" "
oninput="validateCity(this)" onkeypress="return preventInvalidCityChars(event)"
required>
<label for="billing-city" class="input-label">City *</label> <label for="billing-city" class="input-label">City *</label>
</div> </div>
<div class="invalid-feedback" id="billing-city-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="billing-city-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
City name should contain letters and cannot be only numbers or special characters City name should contain letters and cannot be only numbers or special characters
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<!-- <input type="text" id="billing-state" class="input-field" placeholder=" " required> --> <!-- <input type="text" id="billing-state" class="input-field" placeholder=" " required> -->
<select id="billing-state" class="select-field" required> <select id="billing-state" class="select-field" required>
<option value=""></option> <option value="" disable selected style="display: none"></option>
<!-- States will be populated by JavaScript --> <!-- States will be populated by JavaScript -->
</select> </select>
<label for="billing-state" class="input-label">State *</label> <label for="billing-state" class="input-label">State *</label>
@ -211,10 +245,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="billing-zip" class="input-field" placeholder=" " oninput="validateZipCode(this)" onkeypress="return preventNonNumericZip(event)" maxlength="10" required> <input type="text" id="billing-zip" class="input-field" placeholder=" "
oninput="validateZipCode(this)" onkeypress="return preventNonNumericZip(event)"
maxlength="10" required>
<label for="billing-zip" class="input-label">ZIP *</label> <label for="billing-zip" class="input-label">ZIP *</label>
</div> </div>
<div class="invalid-feedback" id="billing-zip-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="billing-zip-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
ZIP code should contain only numbers ZIP code should contain only numbers
</div> </div>
</div> </div>
@ -242,24 +279,31 @@
<div class="error-message" id="step-4-error-message"></div> <div class="error-message" id="step-4-error-message"></div>
</div> </div>
<p style="margin-bottom: 20px; color: #6b7280; font-size: 14px;"> <p style="margin-bottom: 20px; color: #6b7280; font-size: 14px;">
We will use the details below in the agreement that we will send to your prior to entering the project. We will use the details below in the agreement that we will send to your prior to starting the project.
</p> </p>
<form id="signatory-form"> <form id="signatory-form">
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="position" class="input-field" placeholder=" " oninput="validatePosition(this)" onkeypress="return preventInvalidPositionChars(event)" required> <input type="text" id="position" class="input-field" placeholder=" "
<label for="position" class="input-label">Position (Owner / CEO / Manager / etc.) *</label> oninput="validatePosition(this)"
onkeypress="return preventInvalidPositionChars(event)" required>
<label for="position" class="input-label">Position (Owner / CEO / Manager / etc.)
*</label>
</div> </div>
<div class="invalid-feedback" id="position-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="position-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Position should only contain letters, spaces, and common punctuation Position should only contain letters, spaces, and common punctuation
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="signatory-name" class="input-field" placeholder=" " oninput="validateSignatoryName(this)" onkeypress="return preventNumbers(event)" required> <input type="text" id="signatory-name" class="input-field" placeholder=" "
oninput="validateSignatoryName(this)" onkeypress="return preventNumbers(event)"
required>
<label for="signatory-name" class="input-label">Name *</label> <label for="signatory-name" class="input-label">Name *</label>
</div> </div>
<div class="invalid-feedback" id="signatory-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="signatory-name-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Name should not contain numbers Name should not contain numbers
</div> </div>
</div> </div>
@ -297,50 +341,63 @@
<div class="row"> <div class="row">
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="expiry-date" class="input-field" placeholder=" " required> <input type="text" id="expiry-date" class="input-field" placeholder=" "
required>
<label for="expiry-date" class="input-label">Expiry Date (MM/YY) *</label> <label for="expiry-date" class="input-label">Expiry Date (MM/YY) *</label>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="security-code" class="input-field" placeholder=" " oninput="validateCVVVV(this)" nkeypress="return preventInvalidCVVChars(event)" maxlength="4" required> <input type="text" id="security-code" class="input-field" placeholder=" "
oninput="validateCVVVV(this)"
nkeypress="return preventInvalidCVVChars(event)" maxlength="4" required>
<label for="security-code" class="input-label">Security Code *</label> <label for="security-code" class="input-label">Security Code *</label>
</div> </div>
<div class="invalid-feedback" id="security-code-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="security-code-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Please enter a valid security code Please enter a valid security code
</div> </div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="card-zip" class="input-field" placeholder=" " oninput="validateZipCode(this)" onkeypress="return preventNonNumericZip(event)" maxlength="10" required> <input type="text" id="card-zip" class="input-field" placeholder=" "
oninput="validateZipCode(this)" onkeypress="return preventNonNumericZip(event)"
maxlength="10" required>
<label for="card-zip" class="input-label">ZIP Code *</label> <label for="card-zip" class="input-label">ZIP Code *</label>
</div> </div>
<div class="invalid-feedback" id="card-zip-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="card-zip-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
ZIP code should contain only numbers ZIP code should contain only numbers
</div> </div>
</div> </div>
</div> </div>
<div class="payment-option" onclick="selectPaymentOption('bank')"> <div class="payment-option" onclick="selectPaymentOption('bank')">
<h4>Bank Account (ACR)</h4> <h4>Bank Account (ACH)</h4>
<p>Direct bank transfer</p> <p>Direct bank transfer</p>
</div> </div>
<div id="bank-details" class="payment-details"> <div id="bank-details" class="payment-details">
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="bank-name" class="input-field" placeholder=" " oninput="validateBankName(this)" onkeypress="return preventInvalidBankNameChars(event)" required> <input type="text" id="bank-name" class="input-field" placeholder=" "
oninput="validateBankName(this)"
onkeypress="return preventInvalidBankNameChars(event)" required>
<label for="bank-name" class="input-label">Bank Name *</label> <label for="bank-name" class="input-label">Bank Name *</label>
</div> </div>
<div class="invalid-feedback" id="bank-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="bank-name-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Bank name should contain letters and cannot be only numbers or special characters Bank name should contain letters and cannot be only numbers or special characters
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="account-number" class="input-field" placeholder=" " oninput="validateAccountNumber(this)" onkeypress="return preventInvalidAccountNumberChars(event)" required> <input type="text" id="account-number" class="input-field" placeholder=" "
oninput="validateAccountNumber(this)"
onkeypress="return preventInvalidAccountNumberChars(event)" required>
<label for="account-number" class="input-label">Account Number *</label> <label for="account-number" class="input-label">Account Number *</label>
</div> </div>
<div class="invalid-feedback" id="account-number-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="account-number-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Please enter a valid account number Please enter a valid account number
</div> </div>
</div> </div>
@ -353,7 +410,7 @@
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<select id="account-type" class="select-field" required> <select id="account-type" class="select-field" required>
<option value=""></option> <option value="" disable selected style="display: none"></option>
<option value="checking">Checking</option> <option value="checking">Checking</option>
<option value="savings">Savings</option> <option value="savings">Savings</option>
</select> </select>
@ -362,10 +419,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="account-holder-name" class="input-field" placeholder=" " oninput="validateAccountHolderName(this)" onkeypress="return preventInvalidAccountHolderChars(event)" required> <input type="text" id="account-holder-name" class="input-field" placeholder=" "
oninput="validateAccountHolderName(this)"
onkeypress="return preventInvalidAccountHolderChars(event)" required>
<label for="account-holder-name" class="input-label">Account Holder Name *</label> <label for="account-holder-name" class="input-label">Account Holder Name *</label>
</div> </div>
<div class="invalid-feedback" id="account-holder-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="account-holder-name-error"
style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Account holder name should only contain letters and spaces Account holder name should only contain letters and spaces
</div> </div>
</div> </div>
@ -382,13 +442,26 @@
<div class="success-message"> <div class="success-message">
<div class="success-icon">✓</div> <div class="success-icon">✓</div>
<h3>Registration Complete!</h3> <h3>Registration Complete!</h3>
<p>Your account has been successfully created. You will receive a confirmation email shortly.</p> <p>Your account has been successfully created. You will receive a confirmation email shortly.
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script src="/public/js/va-client-register.js"></script> <script src="/public/js/va-client-register.js"></script>
</body> <script>
</html> function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Initialize theme
const savedTheme = getSystemTheme();
setTheme(savedTheme);
</script>

View File

@ -1,287 +1,124 @@
<!DOCTYPE html> <!-- Mobile Toggle Button -->
<html lang="en"> <div class="mobile-toggle" id="toggleBtn">
<span></span>
<span></span>
<span></span>
</div>
<head> <!-- Mobile Overlay -->
<meta charset="UTF-8" /> <div id="overlay"></div>
<title>Live Screen Feed</title>
<style> <div class="sidebar" id="sidebar">
* { <h3>Visible Apps</h3>
box-sizing: border-box; <div id="appsList"></div>
margin: 0; </div>
padding: 0;
<div class="content">
<h2 id="selectedAppTitle">Select an app to view</h2>
<img id="liveImage" alt="Live Feed" />
<div id="placeholder" class="placeholder">No app selected</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
const vaId = "<%= vaId %>";
const clientId = "<%= clientId %>";
const projectId = "<%= projectId %>";
const token = localStorage.getItem("knoptpq");
const socket = io({
auth: {
token: token,
userType: "client",
projectId: projectId,
vaId: vaId
},
});
socket.emit("register", {
role: "client",
id: vaId,
projectId: "<%= projectId %>",
clientId: clientId
});
const sidebar = document.getElementById("sidebar");
const toggleBtn = document.getElementById("toggleBtn");
const overlay = document.getElementById("overlay");
const appsList = document.getElementById("appsList");
const liveImage = document.getElementById("liveImage");
const selectedAppTitle = document.getElementById("selectedAppTitle");
const placeholder = document.getElementById("placeholder");
let currentApp = null;
/* MOBILE SIDEBAR TOGGLE */
toggleBtn.onclick = () => {
sidebar.classList.add("open");
overlay.classList.add("show");
};
overlay.onclick = () => {
sidebar.classList.remove("open");
overlay.classList.remove("show");
};
socket.on("connected", () => {
socket.emit("requestVisibleApps", { token });
});
/* Receive visible apps */
socket.on("receiveVisibleApps", ({ apps, vaId: senderVaId }) => {
if (vaId !== senderVaId) return;
appsList.innerHTML = "";
if (!apps?.length) {
appsList.innerHTML = "<p style='color:#777;'>No visible apps</p>";
return;
} }
body { apps.forEach(app => {
height: 100vh; const div = document.createElement("div");
display: flex; div.classList.add("app");
background: #0f0f0f; div.textContent = app.name || "Unnamed App";
color: white;
font-family: "Inter", sans-serif;
overflow: hidden;
}
/* SIDEBAR */ div.onclick = () => {
.sidebar { document.querySelectorAll(".app").forEach(el => el.classList.remove("active"));
width: 280px; div.classList.add("active");
background: #181818;
border-right: 1px solid #2b2b2b;
padding: 15px;
overflow-y: auto;
transition: transform 0.35s ease;
z-index: 50;
}
.sidebar h3 { currentApp = app.name;
margin-bottom: 15px; selectedAppTitle.textContent = app.name;
font-size: 18px;
font-weight: 600;
}
.app {
background: #252525;
border: 1px solid #333;
padding: 12px;
margin-bottom: 10px;
border-radius: 8px;
cursor: pointer;
transition: 0.3s ease;
}
.app:hover {
background: #333;
transform: translateX(3px);
}
.app.active {
background: #6c63ff;
border-color: #6c63ff;
color: white;
box-shadow: 0 0 10px rgba(108, 99, 255, 0.5);
}
/* MAIN CONTENT */
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding: 20px;
overflow: hidden;
}
#selectedAppTitle {
font-size: 20px;
margin-bottom: 15px;
font-weight: 500;
text-align: center;
}
#liveImage {
width: 90%;
max-width: 900px;
border-radius: 10px;
border: 2px solid #444;
object-fit: contain;
background: black;
display: none;
}
.placeholder {
font-size: 16px;
opacity: 0.8;
color: #bbb;
margin-top: 20px;
}
/* MOBILE RESPONSIVE */
.mobile-toggle {
display: none;
position: fixed;
top: 15px;
left: 15px;
background: #6C63FF;
padding: 14px;
border-radius: 8px;
z-index: 9999;
/* VERY HIGH: always visible */
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.mobile-toggle span {
display: block;
width: 26px;
height: 3px;
background: #fff;
margin: 5px 0;
border-radius: 2px;
}
/* Sidebar Hidden on Mobile */
@media (max-width: 780px) {
body {
flex-direction: column;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100%;
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0%);
}
.mobile-toggle {
display: block !important;
}
.content {
padding-top: 60px;
}
/* Dark overlay when sidebar opens */
#overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: none;
z-index: 40;
}
#overlay.show {
display: block;
}
}
</style>
</head>
<body>
<!-- Mobile Toggle Button -->
<div class="mobile-toggle" id="toggleBtn">
<span></span>
<span></span>
<span></span>
</div>
<!-- Mobile Overlay -->
<div id="overlay"></div>
<div class="sidebar" id="sidebar">
<h3>Visible Apps</h3>
<div id="appsList"></div>
</div>
<div class="content">
<h2 id="selectedAppTitle">Select an app to view</h2>
<img id="liveImage" alt="Live Feed" />
<div id="placeholder" class="placeholder">No app selected</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
const vaId = "<%= vaId %>";
const clientId = "<%= clientId %>";
const projectId = "<%= projectId %>";
const token = localStorage.getItem("knoptpq");
const socket = io({
auth: {
token: token,
userType: "client",
projectId: projectId,
vaId: vaId
},
});
socket.emit("register", {
role: "client",
id: vaId,
projectId: "<%= projectId %>",
clientId: clientId
});
const sidebar = document.getElementById("sidebar");
const toggleBtn = document.getElementById("toggleBtn");
const overlay = document.getElementById("overlay");
const appsList = document.getElementById("appsList");
const liveImage = document.getElementById("liveImage");
const selectedAppTitle = document.getElementById("selectedAppTitle");
const placeholder = document.getElementById("placeholder");
let currentApp = null;
/* MOBILE SIDEBAR TOGGLE */
toggleBtn.onclick = () => {
sidebar.classList.add("open");
overlay.classList.add("show");
};
overlay.onclick = () => {
sidebar.classList.remove("open");
overlay.classList.remove("show");
};
/* Receive visible apps */
socket.on("receiveVisibleApps", ({apps, vaId: senderVaId}) => {
if (vaId !== senderVaId) return;
appsList.innerHTML = "";
if (!apps?.length) {
appsList.innerHTML = "<p style='color:#777;'>No visible apps</p>";
return;
}
apps.forEach(app => {
const div = document.createElement("div");
div.classList.add("app");
div.textContent = app.name || "Unnamed App";
div.onclick = () => {
document.querySelectorAll(".app").forEach(el => el.classList.remove("active"));
div.classList.add("active");
currentApp = app.name;
selectedAppTitle.textContent = app.name;
placeholder.style.display = "none";
liveImage.style.display = "none";
socket.emit("selectApp", { appTitle: app.name, clientId, vaId, projectId });
// Close sidebar on mobile
sidebar.classList.remove("open");
overlay.classList.remove("show");
};
appsList.appendChild(div);
});
});
socket.on("authError", () => {
socket.disconnect();
localStorage.removeItem("knoptpq");
window.location.href = "/client/login";
});
/* Receive images */
socket.on("receiveImage", (base64, title) => {
if (currentApp && title === currentApp) {
liveImage.src = base64;
liveImage.style.display = "block";
placeholder.style.display = "none"; placeholder.style.display = "none";
} liveImage.style.display = "none";
socket.emit("selectApp", { appTitle: app.name, clientId, vaId, projectId });
// Close sidebar on mobile
sidebar.classList.remove("open");
overlay.classList.remove("show");
};
appsList.appendChild(div);
}); });
</script> });
</body> socket.on("authError", () => {
socket.disconnect();
localStorage.removeItem("knoptpq");
window.location.href = "/client/login";
});
</html> /* Receive images */
socket.on("receiveImage", (base64, title) => {
if (currentApp && title === currentApp) {
liveImage.src = base64;
liveImage.style.display = "block";
placeholder.style.display = "none";
}
});
</script>

View File

@ -1,98 +1,4 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Virtual Assistant Registration</title>
<link href="/public/css/va-register.css" rel="stylesheet">
<style>
/* body {
background-color: #f8f9fa;
font-family: 'Segoe UI', sans-serif;
}
.card-registration {
border: none;
border-radius: 12px;
overflow: hidden;
}
.card-registration img {
height: 100%;
object-fit: cover;
}
.card-body {
padding: 3rem !important;
}
h3 {
font-weight: 600;
color: #343a40;
margin-bottom: 2rem;
}
.form-outline {
position: relative;
}
.form-control-lg {
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 1rem;
}
.form-label {
font-weight: 500;
color: #495057;
margin-bottom: 0.5rem;
}
.btn-lg {
border-radius: 8px;
padding: 0.7rem 2rem;
font-size: 1rem;
}
.btn-warning {
background-color: #ffc107;
border: none;
font-weight: 600;
}
.btn-warning:hover {
background-color: #e0a800;
}
.btn-light {
border: 1px solid #ccc;
font-weight: 500;
}
h5 {
font-weight: 600;
color: #495057;
border-bottom: 2px solid #ffc107;
display: inline-block;
padding-bottom: 0.25rem;
margin-top: 2rem;
}
@media (max-width: 991px) {
.card-body {
padding: 2rem !important;
}
.card-registration img {
display: none;
}
} */
</style>
</head>
<body>
<div class="container"> <div class="container">
<!-- Sidebar for desktop --> <!-- Sidebar for desktop -->
@ -171,28 +77,28 @@
<form id="account-form"> <form id="account-form">
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="email" id="email" class="input-field" placeholder=" " required autocomplete="off"> <input type="email" id="email" class="input-field" placeholder=" " value="<%= email %>" readonly disabled required>
<label for="email" class="input-label">Email Address *</label> <label for="email" class="input-label">Email Address *</label>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="password" id="password" class="input-field" placeholder=" " required autocomplete="off"> <input type="password" id="password" class="input-field" placeholder=" " required autocomplete="new-password">
<label for="password" class="input-label">Create Password *</label> <label for="password" class="input-label">Create Password *</label>
</div> </div>
<div class="password-actions">
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()">Show Password</button>
<button type="button" class="password-generate" onclick="generatePassword()">Generate Strong Password</button>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="password" id="confirmPassword" class="input-field" placeholder=" " required autocomplete="off"> <input type="password" id="confirmPassword" class="input-field" placeholder=" " required autocomplete="new-password">
<label for="confirmPassword" class="input-label">Confirm Password *</label> <label for="confirmPassword" class="input-label">Confirm Password *</label>
</div> </div>
<div class="invalid-feedback" id="password-match-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">Passwords do not match</div> <div class="invalid-feedback" id="password-match-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">Passwords do not match</div>
<div class="password-actions">
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()">Show Password</button>
<button type="button" class="password-generate" onclick="generatePassword()">Generate Strong Password</button>
</div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
@ -259,21 +165,32 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<select id="country" class="select-field" required> <select id="country" class="select-field" required onchange="handleChangeSelect(this)">
<option value=""></option> <option value="" disabled selected></option>
</select> </select>
<label for="country" class="input-label">Country *</label> <label for="country" class="input-label">Country *</label>
<!-- <div class="location-loading" id="country-loading" style="display: none;">Detecting your location...</div> -->
</div> </div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<select id="state" class="select-field" required> <input type="text" id="state" class="input-field" placeholder=" " required>
<!-- <select id="state" class="select-field" required onchange="handleChangeSelect(this)">
<option value=""></option> <option value=""></option>
</select> </select> -->
<label for="state" class="input-label">State / Region *</label> <label for="state" class="input-label">State / Region *</label>
<div class="location-loading" id="state-loading">Loading states...</div> <!-- <div class="location-loading" id="state-loading">Loading states...</div> -->
</div>
</div>
<div class="form-group">
<div class="input-container">
<select id="status" class="select-field" required>
<option value="FULLTIME" selected>Full Time</option>
<option value="PARTTIME">Part Time</option>
</select>
<label for="status" class="input-label">Status *</label>
</div> </div>
</div> </div>
@ -295,19 +212,19 @@
<label for="phone" class="input-label">Phone Number *</label> <label for="phone" class="input-label">Phone Number *</label>
<div class="input-container"> <div class="input-container">
<input type="tel" id="phone" class="input-field" placeholder=" " pattern="[0-9]*" minlength="10" maxlength="15" oninput="validatePhoneNumber(this)" required> <input type="tel" id="phone" class="input-field" placeholder=" " pattern="[0-9]*" minlength="10" maxlength="15" oninput="validatePhoneNumber(this)" required>
<label for="phone" class="input-label">+ Phone Number</label> <label for="phone" class="input-label">Phone Number</label>
</div> </div>
<div class="invalid-feedback" id="phone-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="phone-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Please enter a valid phone number (10-15 digits) Please enter a valid phone number (10-15 digits)
</div> </div>
</div> </div>
<div class="form-group"> <!-- <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="skype" class="input-field" placeholder=" "> <input type="text" id="skype" class="input-field" placeholder=" " oninput="validateSkypeId(this)">
<label for="skype" class="input-label">Skype ID</label> <label for="skype" class="input-label">Skype ID</label>
</div> </div>
</div> </div> -->
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(2)">Back</button> <button type="button" class="btn btn-secondary" onclick="prevStep(2)">Back</button>
@ -324,22 +241,6 @@
</div> </div>
<form id="payment-form"> <form id="payment-form">
<div class="form-group"> <div class="form-group">
<div class="payment-option" onclick="selectPaymentMethod('payoneer')">
<h4>Payoneer</h4>
<p>Receive payments via Payoneer</p>
</div>
<div id="payoneer-details" class="payment-details">
<div class="form-group">
<div class="input-container">
<input type="text" id="payoneerAccount" class="input-field" placeholder=" " minlength="5" maxlength="20" oninput="validatePayoneerAccount(this)" required>
<label for="payoneerAccount" class="input-label">Payoneer Account Number *</label>
</div>
<div class="invalid-feedback" id="payoneer-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Account number must be 5-20 characters
</div>
</div>
</div>
<div class="payment-option" onclick="selectPaymentMethod('veem')"> <div class="payment-option" onclick="selectPaymentMethod('veem')">
<h4>Veem</h4> <h4>Veem</h4>
<p>Receive payments via Veem</p> <p>Receive payments via Veem</p>
@ -347,11 +248,44 @@
<div id="veem-details" class="payment-details"> <div id="veem-details" class="payment-details">
<div class="form-group"> <div class="form-group">
<div class="input-container"> <div class="input-container">
<input type="text" id="veemAccount" class="input-field" placeholder=" " minlength="8" maxlength="25" oninput="validateVeemAccount(this)" required> <input type="email" id="veemAccount" class="input-field" placeholder=" " oninput="validateVeemEmail(this)" required>
<label for="veemAccount" class="input-label">Veem Account Number *</label> <label for="veemAccount" class="input-label">Veem Email Address *</label>
</div> </div>
<div class="invalid-feedback" id="veem-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;"> <div class="invalid-feedback" id="veem-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Account number must be 8-25 characters Invalid Veem email address
</div>
</div>
</div>
<div class="payment-option" onclick="selectPaymentMethod('wise')">
<h4>Wise</h4>
<p>Receive payments via Wise</p>
</div>
<div id="wise-details" class="payment-details">
<div class="form-group">
<div class="input-container">
<input type="email" id="wiseEmail" class="input-field" placeholder=" " oninput="validateWiseEmail(this)" required>
<label for="wiseEmail" class="input-label">Wise Email Address *</label>
</div>
<div class="invalid-feedback" id="wise-email-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Please enter a valid email address for Wise
</div>
</div>
</div>
<h2 class="form-title additional-info-heading">Additional Information</h2>
<div class="payment-option" onclick="selectPaymentMethod('payoneer')">
<h4>Payoneer</h4>
<p>Receive payments via Payoneer</p>
</div>
<div id="payoneer-details" class="payment-details">
<div class="form-group">
<div class="input-container">
<input type="email" id="payoneerAccount" class="input-field" placeholder=" " oninput="validatePayoneerEmail(this)" required>
<label for="payoneerAccount" class="input-label">Payoneer Email Address *</label>
</div>
<div class="invalid-feedback" id="payoneer-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
Please enter a valid email address for Payoneer
</div> </div>
</div> </div>
</div> </div>
@ -377,5 +311,18 @@
</div> </div>
</div> </div>
<script src="/public/js/va-register.js"></script> <script src="/public/js/va-register.js"></script>
</body> <script>
</html> function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Initialize theme
const savedTheme = getSystemTheme();
setTheme(savedTheme);
</script>