Compare commits
15 Commits
36bdc24b54
...
904d9a1a93
| Author | SHA1 | Date |
|---|---|---|
|
|
904d9a1a93 | |
|
|
dea2a2985e | |
|
|
dc085787a3 | |
|
|
bbc9793614 | |
|
|
5eef945f50 | |
|
|
0b9d6f801e | |
|
|
a48a919b41 | |
|
|
c1cf57e0f4 | |
|
|
db51e213f1 | |
|
|
aecb4af2ac | |
|
|
d49ba5a8b1 | |
|
|
e1fc8d9d13 | |
|
|
e9a2ace3db | |
|
|
9429caacd6 | |
|
|
d50c18856f |
|
|
@ -80,7 +80,6 @@ body {
|
|||
padding: 0 1.5rem;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
|
@ -188,7 +187,10 @@ body {
|
|||
width: 16rem;
|
||||
background-color: var(--bg-card);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: none;
|
||||
/* display: none; */
|
||||
}
|
||||
.dashboard-sidebar.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
|
@ -196,6 +198,20 @@ body {
|
|||
display: block;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-sidebar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
top: 0;
|
||||
transform: translateX(-100%);
|
||||
transition: .3s ease;
|
||||
}
|
||||
.dashboard-sidebar.active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.5rem;
|
||||
|
|
@ -945,4 +961,4 @@ tr[data-vaid]:hover {
|
|||
.detail-item span {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -475,4 +475,43 @@ body {
|
|||
font-size: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-error-container {
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
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;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -109,7 +109,22 @@
|
|||
|
||||
|
||||
// Add input event listeners to handle label animation
|
||||
document.querySelectorAll('.input-field').forEach(input => {
|
||||
document.querySelectorAll('.input-field:not(.dob)').forEach(input => {
|
||||
setTimeout(() => {
|
||||
if (input.value) {
|
||||
input.nextElementSibling.style.top = '4px';
|
||||
input.nextElementSibling.style.fontSize = '12px';
|
||||
input.nextElementSibling.style.transform = 'none';
|
||||
}
|
||||
}, 100);
|
||||
|
||||
input.addEventListener('animationstart', function() {
|
||||
if (this.value) {
|
||||
this.nextElementSibling.style.top = '4px';
|
||||
this.nextElementSibling.style.fontSize = '12px';
|
||||
this.nextElementSibling.style.transform = 'none';
|
||||
}
|
||||
});
|
||||
input.addEventListener('input', function() {
|
||||
if (this.value) {
|
||||
this.nextElementSibling.style.top = '4px';
|
||||
|
|
@ -139,6 +154,13 @@
|
|||
|
||||
// Handle select field label animation
|
||||
document.querySelectorAll('.select-field').forEach(select => {
|
||||
setTimeout(() => {
|
||||
if (select.value) {
|
||||
select.nextElementSibling.style.top = '4px';
|
||||
select.nextElementSibling.style.fontSize = '12px';
|
||||
select.nextElementSibling.style.transform = 'none';
|
||||
}
|
||||
}, 100);
|
||||
select.addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
this.nextElementSibling.style.top = '4px';
|
||||
|
|
@ -228,10 +250,14 @@
|
|||
return maxStep;
|
||||
}
|
||||
|
||||
async function handleNextStep(step) {
|
||||
await nextStep(step);
|
||||
}
|
||||
|
||||
// Function to navigate to next step
|
||||
function nextStep(step) {
|
||||
async function nextStep(step) {
|
||||
// Validate current step before proceeding
|
||||
if (!validateStep(currentStep)) {
|
||||
if (!(await validateStep(currentStep))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -306,9 +332,13 @@
|
|||
}
|
||||
|
||||
// Function to validate current step
|
||||
function validateStep(step) {
|
||||
async function validateStep(step) {
|
||||
let isValid = true;
|
||||
const form = document.getElementById(getFormId(step));
|
||||
|
||||
// Hide any previous errors for this step
|
||||
hideError(step);
|
||||
clearFieldErrors();
|
||||
|
||||
if (form) {
|
||||
// For account setup step
|
||||
|
|
@ -319,21 +349,31 @@
|
|||
|
||||
// Email validation
|
||||
if (!validateEmail(email)) {
|
||||
alert('Please enter a valid email address');
|
||||
showError('Please enter a valid email address', 'email', step);
|
||||
isValid = false;
|
||||
} else {
|
||||
// Email verification - wait for the result
|
||||
const isEmailVerified = await verifyEmail(email);
|
||||
if (!isEmailVerified) {
|
||||
showError('Email already registered. Please use a different email address.', 'email', step);
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Password validation
|
||||
if (password.length < 8) {
|
||||
alert('Password must be at least 8 characters long');
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -342,15 +382,18 @@
|
|||
const firstName = document.getElementById('firstName').value;
|
||||
if (!firstName) {
|
||||
document.getElementById('profile-incomplete').style.display = 'block';
|
||||
document.getElementById('firstName').classList.add('field-error');
|
||||
showError('First name is required', 'firstName', step);
|
||||
isValid = false;
|
||||
} else {
|
||||
document.getElementById('profile-incomplete').style.display = 'none';
|
||||
document.getElementById('firstName').classList.remove('field-error');
|
||||
}
|
||||
|
||||
// Age validation
|
||||
const age = document.getElementById('age').value;
|
||||
if (age && (age < 18 || age > 100)) {
|
||||
alert('You must be 18 years or older to register');
|
||||
showError('You age must be 18 years or older to register', 'dob', step);
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
|
|
@ -359,12 +402,12 @@
|
|||
const state = document.getElementById('state').value;
|
||||
|
||||
if (!country) {
|
||||
alert('Please select your country');
|
||||
showError('Please select your country', 'country', step);
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
alert('Please select your state/region');
|
||||
showError('Please select your state/region', 'state', step);
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
|
|
@ -373,9 +416,40 @@
|
|||
requiredFields.forEach(field => {
|
||||
if (!field.value.trim()) {
|
||||
isValid = false;
|
||||
field.style.border = '1px solid #ef4444';
|
||||
field.classList.add('field-error');
|
||||
if (isValid === false) {
|
||||
showError(`Please fill in ${field.labels[0]?.textContent || 'this field'}`, field.id, step);
|
||||
}
|
||||
} else {
|
||||
field.style.border = '';
|
||||
field.classList.remove('field-error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (step === 3) {
|
||||
// Phone number validation
|
||||
const phone = document.getElementById('phone').value;
|
||||
if (!phone) {
|
||||
showError('Please enter your phone number', 'phone', step);
|
||||
isValid = false;
|
||||
} else if (!validatePhoneNumberStrict(phone)) {
|
||||
document.getElementById('phone-error').style.display = 'block';
|
||||
document.getElementById('phone').classList.add('field-error');
|
||||
showError('Please enter a valid phone number (10-15 digits)', 'phone', step);
|
||||
isValid = false;
|
||||
} else {
|
||||
document.getElementById('phone-error').style.display = 'none';
|
||||
document.getElementById('phone').classList.remove('field-error');
|
||||
}
|
||||
|
||||
// Validate other required fields in step 3
|
||||
const requiredFields = form.querySelectorAll('[required]');
|
||||
requiredFields.forEach(field => {
|
||||
if (!field.value.trim()) {
|
||||
isValid = false;
|
||||
field.classList.add('field-error');
|
||||
} else {
|
||||
field.classList.remove('field-error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -384,20 +458,20 @@
|
|||
if (step === 4) {
|
||||
const selectedPayment = document.querySelector('.payment-option.selected');
|
||||
if (!selectedPayment) {
|
||||
alert('Please select a payment method');
|
||||
showError('Please select a payment method', null, step);
|
||||
isValid = false;
|
||||
} else {
|
||||
const paymentMethod = selectedPayment.getAttribute('onclick').match(/'([^']+)'/)[1];
|
||||
if (paymentMethod === 'payoneer') {
|
||||
const payoneerAccount = document.getElementById('payoneerAccount').value;
|
||||
if (!payoneerAccount) {
|
||||
alert('Please enter your Payoneer account number');
|
||||
showError('Please enter your Payoneer account number', 'payoneerAccount', step);
|
||||
isValid = false;
|
||||
}
|
||||
} else if (paymentMethod === 'veem') {
|
||||
const veemAccount = document.getElementById('veemAccount').value;
|
||||
if (!veemAccount) {
|
||||
alert('Please enter your Veem account number');
|
||||
showError('Please enter your Veem account number', 'veemAccount', step);
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -410,9 +484,9 @@
|
|||
requiredFields.forEach(field => {
|
||||
if (!field.value.trim()) {
|
||||
isValid = false;
|
||||
field.style.border = '1px solid #ef4444';
|
||||
field.classList.add('field-error');
|
||||
} else {
|
||||
field.style.border = '';
|
||||
field.classList.remove('field-error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -598,12 +672,12 @@
|
|||
}, 1000);
|
||||
} else {
|
||||
// Handle server errors
|
||||
alert('There was an error submitting your registration. Please try again.');
|
||||
showError('There was an error submitting your registration. Please try again.', null, 4);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle network errors
|
||||
console.error('Error:', error);
|
||||
alert('There was a network error. Please check your connection and try again.');
|
||||
showError('There was a network error. Please check your connection and try again.', null, 4);
|
||||
} finally {
|
||||
// Restore button state
|
||||
submitBtn.textContent = originalText;
|
||||
|
|
@ -611,3 +685,220 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function verifyEmail(email) {
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/va/verify-va-email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({"email": email})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle network errors
|
||||
console.error('Error:', error);
|
||||
showError('There was a network error. Please check your connection and try again.', null, 1);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to calculate age from date of birth
|
||||
function calculateAge(dob) {
|
||||
if (!dob) return '';
|
||||
|
||||
const birthDate = new Date(dob);
|
||||
const today = new Date();
|
||||
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
|
||||
// Adjust age if birthday hasn't occurred this year yet
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--;
|
||||
}
|
||||
|
||||
return age;
|
||||
}
|
||||
|
||||
// Function to handle date of birth change and update age
|
||||
function handleDOBChange() {
|
||||
const dob = document.getElementById('dob').value;
|
||||
const ageInput = document.getElementById('age');
|
||||
|
||||
if (dob) {
|
||||
const age = calculateAge(dob);
|
||||
ageInput.value = age;
|
||||
|
||||
// Trigger input event to update the label position
|
||||
ageInput.dispatchEvent(new Event('input'));
|
||||
|
||||
// Validate age
|
||||
if (age < 18) {
|
||||
// alert('You must be 18 years or older to register');
|
||||
showError('You Age must be 18 years or older to register', 'dob',);
|
||||
ageInput.style.border = '1px solid #ef4444';
|
||||
} else if (age > 100) {
|
||||
// alert('Please enter a valid age');
|
||||
showError('Please enter a valid age', 'dob');
|
||||
ageInput.style.border = '1px solid #ef4444';
|
||||
} else {
|
||||
ageInput.style.border = '';
|
||||
}
|
||||
} else {
|
||||
// Clear age if DOB is cleared
|
||||
ageInput.value = '';
|
||||
ageInput.dispatchEvent(new Event('input'));
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle age manual input and update DOB (optional)
|
||||
function handleAgeChange() {
|
||||
const age = document.getElementById('age').value;
|
||||
const dobInput = document.getElementById('dob');
|
||||
|
||||
if (age && age >= 18 && age <= 100) {
|
||||
// Calculate approximate DOB based on age
|
||||
const today = new Date();
|
||||
const birthYear = today.getFullYear() - age;
|
||||
const approximateDOB = `${birthYear}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// You can set this as the DOB or just leave it for reference
|
||||
// dobInput.value = approximateDOB;
|
||||
// dobInput.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
// Function to validate phone number in real-time
|
||||
function validatePhoneNumber(input) {
|
||||
const phoneError = document.getElementById('phone-error');
|
||||
const value = input.value;
|
||||
|
||||
// Remove any non-digit characters
|
||||
const digitsOnly = value.replace(/\D/g, '');
|
||||
|
||||
// Update the input value with digits only
|
||||
if (value !== digitsOnly) {
|
||||
input.value = digitsOnly;
|
||||
}
|
||||
|
||||
// Validate length
|
||||
if (digitsOnly.length > 0 && (digitsOnly.length < 10 || digitsOnly.length > 15)) {
|
||||
phoneError.style.display = 'block';
|
||||
input.style.border = '1px solid #ef4444';
|
||||
return false;
|
||||
} else {
|
||||
phoneError.style.display = 'none';
|
||||
input.style.border = '';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to validate phone number on form submission
|
||||
function validatePhoneNumberStrict(phone) {
|
||||
const phoneRegex = /^[0-9]{10,15}$/;
|
||||
return phoneRegex.test(phone);
|
||||
}
|
||||
|
||||
// Function to validate Payoneer account number
|
||||
function validatePayoneerAccount(input) {
|
||||
const payoneerError = document.getElementById('payoneer-error');
|
||||
const value = input.value.trim();
|
||||
|
||||
// Payoneer typically uses email format or alphanumeric IDs
|
||||
// Adjust validation rules based on Payoneer's actual format requirements
|
||||
if (value.length > 0 && (value.length < 5 || value.length > 20)) {
|
||||
payoneerError.textContent = 'Payoneer account must be 5-20 characters';
|
||||
payoneerError.style.display = 'block';
|
||||
input.style.border = '1px solid #ef4445';
|
||||
return false;
|
||||
} else {
|
||||
payoneerError.style.display = 'none';
|
||||
input.style.border = '';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to validate Veem account number
|
||||
function validateVeemAccount(input) {
|
||||
const veemError = document.getElementById('veem-error');
|
||||
const value = input.value.trim();
|
||||
|
||||
// Veem account numbers are typically longer alphanumeric strings
|
||||
if (value.length > 0 && (value.length < 8 || value.length > 25)) {
|
||||
veemError.textContent = 'Veem account must be 8-25 characters';
|
||||
veemError.style.display = 'block';
|
||||
input.style.border = '1px solid #ef4445';
|
||||
return false;
|
||||
} else {
|
||||
veemError.style.display = 'none';
|
||||
input.style.border = '';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message, fieldId = null, step = null) {
|
||||
// Determine the current step if not provided
|
||||
const currentStep = step || getCurrentStep();
|
||||
const errorContainer = document.getElementById(`step-${currentStep}-error`);
|
||||
const errorMessage = document.getElementById(`step-${currentStep}-error-message`);
|
||||
|
||||
if (errorContainer && errorMessage) {
|
||||
errorMessage.textContent = message;
|
||||
errorContainer.style.display = 'block';
|
||||
|
||||
// Scroll to error container
|
||||
errorContainer.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// Highlight specific field if provided
|
||||
if (fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field) {
|
||||
field.classList.add('field-error');
|
||||
// Remove error after 5 seconds
|
||||
setTimeout(() => {
|
||||
field.classList.remove('field-error');
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-hide error after 8 seconds
|
||||
setTimeout(() => {
|
||||
hideError(currentStep);
|
||||
}, 8000);
|
||||
}
|
||||
}
|
||||
|
||||
function hideError(step = null) {
|
||||
if (step) {
|
||||
// Hide specific step error
|
||||
const errorContainer = document.getElementById(`step-${step}-error`);
|
||||
if (errorContainer) {
|
||||
errorContainer.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// Hide all step errors
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const errorContainer = document.getElementById(`step-${i}-error`);
|
||||
if (errorContainer) {
|
||||
errorContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearFieldErrors() {
|
||||
document.querySelectorAll('.field-error').forEach(field => {
|
||||
field.classList.remove('field-error');
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentStep() {
|
||||
return currentStep;
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { ClientModel, ProjectModel, VAModel } from "../../../models/vaModel.js";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
function formatHoursToReadable(hours) {
|
||||
const wholeHours = Math.floor(hours);
|
||||
const decimalMinutes = (hours - wholeHours) * 60;
|
||||
const wholeMinutes = Math.round(decimalMinutes);
|
||||
|
||||
if (wholeHours > 0 && wholeMinutes > 0) {
|
||||
return `${wholeHours}h ${wholeMinutes}m`;
|
||||
} else if (wholeHours > 0) {
|
||||
return `${wholeHours}h`;
|
||||
} else {
|
||||
return `${wholeMinutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Getting client dashboard data - all VAs and projects
|
||||
async function client_dashboard_projects(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" });
|
||||
}
|
||||
|
||||
// Get all VA IDs and Project IDs assigned to this client
|
||||
const clientVaIds = clientUser.vaIds || [];
|
||||
const clientProjectIds = clientUser.projectIds || [];
|
||||
|
||||
// Get all VAs assigned to this client (with required fields only)
|
||||
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({
|
||||
_id: { $in: clientProjectIds }
|
||||
})
|
||||
.select('name hourlyRate vaAssigned dedicatedHours status')
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
// Format VAs with only required fields
|
||||
const vasFormatted = allAssignedVAs.map((va) => ({
|
||||
id: va._id,
|
||||
firstName: va.userDetails?.firstName || '',
|
||||
lastName: va.userDetails?.lastName || '',
|
||||
email: va.userDetails?.email || '',
|
||||
status: va.status || ''
|
||||
}));
|
||||
|
||||
// Format projects with only required fields
|
||||
const projectsFormatted = clientProjects.map((project) => ({
|
||||
id: project._id,
|
||||
name: project.name,
|
||||
hourlyRate: project.hourlyRate,
|
||||
vaAssigned: project.vaAssigned.length || 0,
|
||||
dedicatedHours: formatHoursToReadable(project.dedicatedHours) || 0,
|
||||
status: project.status || ''
|
||||
}));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
vas: vasFormatted,
|
||||
projects: projectsFormatted
|
||||
},
|
||||
counts: {
|
||||
totalVAs: vasFormatted.length,
|
||||
totalProjects: projectsFormatted.length
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Client dashboard error:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal Server Error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { client_dashboard_projects };
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { ClientModel } from "../../models/vaModel.js";
|
||||
|
||||
// Verify VA Email
|
||||
async function client_verify_email(req, res) {
|
||||
try {
|
||||
// const payload = req.body ? JSON.parse(req.body) : {};
|
||||
const payload = req.body || {};
|
||||
const { email } = payload;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ success: false, message: "Please provide email" });
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await ClientModel.findOne({ "userDetails.email": email });
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: "Email not found" });
|
||||
}
|
||||
|
||||
// Return accessToken and user info
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
email: user.userDetails?.email,
|
||||
message: "Email ID is already registerd"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("va_register error:", error);
|
||||
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
||||
}
|
||||
}
|
||||
|
||||
export { client_verify_email };
|
||||
|
|
@ -71,7 +71,7 @@ async function va_login_client(req, res) {
|
|||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("va_register error:", error);
|
||||
console.error("va_client_login_register error:", error);
|
||||
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,101 @@
|
|||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { VAModel, LoggedModel } from "../../../models/vaModel.js";
|
||||
import { VAModel, LoggedModel, WorkTrackModel } from "../../../models/vaModel.js";
|
||||
|
||||
// function to close open work tracks and create logout
|
||||
async function closePreviousSession(vaId, lastLog) {
|
||||
try {
|
||||
const currentTime = new Date();
|
||||
// Find all open work tracks for this VA and project (where to is not set or totalTime is 0)
|
||||
const openWorkTracks = await WorkTrackModel.find({
|
||||
vaId,
|
||||
logId: lastLog._id,
|
||||
$or: [
|
||||
{ to: { $exists: false } },
|
||||
{ totalTime: 0 },
|
||||
{ to: null }
|
||||
]
|
||||
});
|
||||
|
||||
// console.log(`Found ${openWorkTracks.length} open work tracks to close`);
|
||||
|
||||
// Close all open work tracks
|
||||
for (const workTrack of openWorkTracks) {
|
||||
const fromTime = new Date(workTrack.from);
|
||||
const totalTimeMs = currentTime - fromTime;
|
||||
|
||||
// Update the work track with end time and total time
|
||||
workTrack.to = currentTime;
|
||||
workTrack.totalTime = totalTimeMs;
|
||||
await workTrack.save();
|
||||
|
||||
// console.log(`Closed work track ${workTrack._id} with duration: ${totalTimeMs}ms`);
|
||||
}
|
||||
|
||||
// Create a logout log entry
|
||||
const logoutLog = await LoggedModel.create({
|
||||
loggedType: "LOGOUT",
|
||||
vaId,
|
||||
vaEmail: lastLog.vaEmail, // Use email from previous log
|
||||
projectId: lastLog.projectId,
|
||||
time: currentTime // Use same timestamp for consistent logging
|
||||
});
|
||||
|
||||
// console.log(`Created logout log: ${logoutLog._id}`);
|
||||
// try {
|
||||
// const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
|
||||
// const sfDataSync = {
|
||||
// "_id": logoutLog._id,
|
||||
// "loggedType": "LOGOUT",
|
||||
// "vaId": vaId,
|
||||
// "vaEmail": lastLog.vaEmail,
|
||||
// "projectId": lastLog.projectId,
|
||||
// "time": logoutLog.time,
|
||||
// }
|
||||
|
||||
// const response = await fetch(salesforceUrl, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add any required headers for Salesforce authentication
|
||||
// // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
|
||||
// },
|
||||
// body: JSON.stringify(sfDataSync)
|
||||
// });
|
||||
|
||||
// if (!response.ok) {
|
||||
// const errorText = await response.text();
|
||||
// throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
|
||||
// }
|
||||
|
||||
// const result = await response.json();
|
||||
// // console.log("✅ Successfully synced VA data to Salesforce:", result);
|
||||
|
||||
// return {
|
||||
// closedWorkTracks: openWorkTracks.length,
|
||||
// logoutLogId: logoutLog._id,
|
||||
// SyncSalesforce: result
|
||||
// };
|
||||
// } catch (error) {
|
||||
// console.error("Failed to sync with Salesforce:", error.message);
|
||||
// console.log(error)
|
||||
|
||||
// // Create a custom error to distinguish Salesforce errors
|
||||
// const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
|
||||
// salesforceError.isSalesforceError = true;
|
||||
// throw salesforceError;
|
||||
// }
|
||||
|
||||
return {
|
||||
closedWorkTracks: openWorkTracks.length,
|
||||
logoutLogId: logoutLog._id,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error closing previous session:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Login VA
|
||||
async function va_login_callback(req, res) {
|
||||
|
|
@ -32,36 +127,22 @@ async function va_login_callback(req, res) {
|
|||
return res.status(401).json({ success: false, message: "Invalid password" });
|
||||
}
|
||||
|
||||
|
||||
// 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)
|
||||
const activeSessions = await LoggedModel.find({
|
||||
vaId: user._id,
|
||||
loggedType: "LOGIN"
|
||||
vaId: user._id
|
||||
}).sort({ time: -1 });
|
||||
|
||||
|
||||
let closeResult = [];
|
||||
|
||||
if (activeSessions.length > 0) {
|
||||
// Get the latest login to check if there's no corresponding logout
|
||||
const latestLogin = activeSessions[0];
|
||||
const latestLog = activeSessions[0];
|
||||
closeResult = await closePreviousSession(user._id, latestLog);
|
||||
}
|
||||
|
||||
// Check if there's a logout after this login
|
||||
const correspondingLogout = await LoggedModel.findOne({
|
||||
vaId: user._id,
|
||||
loggedType: "LOGOUT",
|
||||
time: { $gt: latestLogin.time }
|
||||
});
|
||||
|
||||
if (!correspondingLogout) {
|
||||
// VA is still logged in to a project
|
||||
const projectInfo = await LoggedModel.findById(latestLogin._id)
|
||||
.populate('projectId', 'name')
|
||||
.lean();
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "You are still logged in to a project. Please logout first before logging in again."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT accessToken with 24h expiry
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
|
|
@ -88,18 +169,40 @@ async function va_login_callback(req, res) {
|
|||
$set: { accessToken, refreshToken }
|
||||
})
|
||||
|
||||
let responseData = [];
|
||||
|
||||
if (closeResult) {
|
||||
responseData = {
|
||||
success: true,
|
||||
message: "Login Successfull",
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user._id,
|
||||
name: user.userDetails.firstName,
|
||||
email: user.userDetails.email,
|
||||
},
|
||||
previousSession: {
|
||||
closedWorkTracks: closeResult.closedWorkTracks,
|
||||
logoutLogId: closeResult.logoutLogId
|
||||
}
|
||||
}
|
||||
} else {
|
||||
responseData = {
|
||||
success: true,
|
||||
message: "Login Successfull",
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user._id,
|
||||
name: user.userDetails.firstName,
|
||||
email: user.userDetails.email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return accessToken and user info
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Login Successfull",
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user._id,
|
||||
name: user.userDetails.firstName,
|
||||
email: user.userDetails.email,
|
||||
},
|
||||
});
|
||||
return res.status(200).json(responseData);
|
||||
} catch (error) {
|
||||
console.error("va_register error:", error);
|
||||
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { VAModel } from "../../../models/vaModel.js";
|
||||
|
||||
// Verify VA Email
|
||||
async function va_verify_email(req, res) {
|
||||
try {
|
||||
// const payload = req.body ? JSON.parse(req.body) : {};
|
||||
const payload = req.body || {};
|
||||
const { email } = payload;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ success: false, message: "Please provide email" });
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await VAModel.findOne({ "userDetails.email": email });
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: "Invalid Email ID" });
|
||||
}
|
||||
|
||||
// Return accessToken and user info
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
email: user.userDetails?.email,
|
||||
message: "Email verified successfully"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("va_register error:", error);
|
||||
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
||||
}
|
||||
}
|
||||
|
||||
export { va_verify_email };
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { VAModel, LeavesModel } from "../../../models/vaModel.js";
|
||||
|
||||
// Get all leave lists applied by VA
|
||||
async function va_leave_list_function(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 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;
|
||||
|
||||
// Get parameters from route params (they are optional)
|
||||
const { status, type } = req.params;
|
||||
|
||||
// Get sorting from query params (optional)
|
||||
const {
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc'
|
||||
} = req.query;
|
||||
|
||||
// Build filter object
|
||||
const filter = { vaId };
|
||||
|
||||
// Handle status filter from params
|
||||
if (status && status !== 'ALL') {
|
||||
filter.status = status.toUpperCase();
|
||||
}
|
||||
|
||||
// Handle type filter from params (using 'type' instead of 'leaveType')
|
||||
if (type && type !== 'ALL') {
|
||||
filter.leaveType = type.toUpperCase();
|
||||
}
|
||||
|
||||
// Validate sort parameters
|
||||
const validSortFields = ['createdAt', 'dateFrom', 'dateTo', 'status', 'leaveType'];
|
||||
const sortField = validSortFields.includes(sortBy) ? sortBy : 'createdAt';
|
||||
const sortDirection = sortOrder.toLowerCase() === 'asc' ? 1 : -1;
|
||||
|
||||
// Get all leaves without pagination
|
||||
const leaves = await LeavesModel.find(filter)
|
||||
.sort({ [sortField]: sortDirection })
|
||||
.select('-__v')
|
||||
.lean();
|
||||
|
||||
// Format the response data
|
||||
const formattedLeaves = leaves.map(leave => ({
|
||||
leaveId: leave._id,
|
||||
dateFrom: leave.dateFrom.toISOString().split('T')[0],
|
||||
dateTo: leave.dateTo.toISOString().split('T')[0],
|
||||
totalDays: parseInt(leave.days),
|
||||
leaveType: leave.leaveType,
|
||||
comment: leave.comment,
|
||||
status: leave.status,
|
||||
appliedOn: leave.createdAt,
|
||||
reviewedAt: leave.reviewedAt || null,
|
||||
rejectionReason: leave.rejectionReason || null
|
||||
}));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Leave list retrieved successfully",
|
||||
data: {
|
||||
leaves: formattedLeaves,
|
||||
totalCount: formattedLeaves.length
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Leave list error:", error);
|
||||
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ success: false, message: "Invalid token" });
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ success: false, message: "Token expired" });
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal Server Error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { va_leave_list_function };
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { VAModel, LeavesModel } from "../../../models/vaModel.js";
|
||||
|
||||
// Approve or reject leave
|
||||
async function va_leave_manage_function(req, res) {
|
||||
try {
|
||||
|
||||
const { leaveId } = req.params;
|
||||
const { action, rejectionReason } = req.body;
|
||||
|
||||
if (!leaveId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Leave ID is required"
|
||||
});
|
||||
}
|
||||
|
||||
if (!action || !['APPROVE', 'REJECT'].includes(action.toUpperCase())) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Action is required and must be either 'APPROVE' or 'REJECT'"
|
||||
});
|
||||
}
|
||||
|
||||
// If rejecting, rejectionReason is required
|
||||
if (action.toUpperCase() === 'REJECT' && !rejectionReason) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Rejection reason is required when rejecting a leave"
|
||||
});
|
||||
}
|
||||
|
||||
// Find the leave request
|
||||
const leaveRequest = await LeavesModel.findById(leaveId);
|
||||
if (!leaveRequest) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Leave request not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Check if leave is already processed
|
||||
if (leaveRequest.status !== 'PENDING') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Leave request is already ${leaveRequest.status.toLowerCase()}`
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
status: action.toUpperCase() === 'APPROVE' ? 'APPROVED' : 'REJECTED',
|
||||
reviewedAt: new Date()
|
||||
};
|
||||
|
||||
// Add rejection reason if rejecting
|
||||
if (action.toUpperCase() === 'REJECT') {
|
||||
updateData.rejectionReason = rejectionReason;
|
||||
}
|
||||
|
||||
// Update the leave request
|
||||
const updatedLeave = await LeavesModel.findByIdAndUpdate(
|
||||
leaveId,
|
||||
updateData,
|
||||
{ new: true } // Return updated document
|
||||
).select('-__v').lean();
|
||||
|
||||
// Format the response
|
||||
const formattedLeave = {
|
||||
leaveId: updatedLeave._id,
|
||||
dateFrom: updatedLeave.dateFrom.toISOString().split('T')[0],
|
||||
dateTo: updatedLeave.dateTo.toISOString().split('T')[0],
|
||||
totalDays: parseInt(updatedLeave.days),
|
||||
leaveType: updatedLeave.leaveType,
|
||||
comment: updatedLeave.comment,
|
||||
status: updatedLeave.status,
|
||||
appliedOn: updatedLeave.createdAt,
|
||||
reviewedAt: updatedLeave.reviewedAt,
|
||||
rejectionReason: updatedLeave.rejectionReason || null
|
||||
};
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: `Leave request ${action.toLowerCase()}d successfully`,
|
||||
data: formattedLeave
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Leave approve/reject error:", error);
|
||||
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ success: false, message: "Invalid token" });
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ success: false, message: "Token expired" });
|
||||
}
|
||||
|
||||
if (error.name === 'CastError') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid leave ID format"
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal Server Error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { va_leave_manage_function };
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { VAModel, LeavesModel } from "../../../models/vaModel.js";
|
||||
|
||||
// Apply for leave
|
||||
async function va_leave_apply_function(req, res) {
|
||||
try {
|
||||
const accessToken = req.headers.token;
|
||||
|
||||
if (!accessToken) {
|
||||
return res.status(401).json({ success: false, message: "Missing token" });
|
||||
}
|
||||
|
||||
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 { dateFrom, dateTo, leaveType, comment } = req.body;
|
||||
|
||||
if (!dateFrom || !dateTo || !leaveType || !comment) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "dateFrom, dateTo, leaveType, and comment are required"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate date formats
|
||||
let startDate, endDate;
|
||||
try {
|
||||
startDate = new Date(dateFrom);
|
||||
endDate = new Date(dateTo);
|
||||
|
||||
// Set to start of day for accurate calculation
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
endDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid date format. Use valid date format (YYYY-MM-DD)"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid date format"
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure start date is before or equal to end date
|
||||
if (startDate > endDate) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Start date must be before or equal to end date"
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure dates are not in the past
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (startDate < today) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Cannot apply for leave for past dates"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate leave type
|
||||
const validLeaveTypes = ["SICK", "PTO"];
|
||||
if (!validLeaveTypes.includes(leaveType)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid leave type. Must be SICK or PTO"
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total days (inclusive of both start and end dates)
|
||||
const timeDifference = endDate.getTime() - startDate.getTime();
|
||||
const totalDays = Math.floor(timeDifference / (1000 * 60 * 60 * 24)) + 1; // +1 to include both start and end dates
|
||||
|
||||
if (totalDays <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid date range"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for overlapping leave requests
|
||||
const existingLeave = await LeavesModel.findOne({
|
||||
vaId,
|
||||
status: "PENDING",
|
||||
$or: [
|
||||
{
|
||||
dateFrom: { $lte: endDate },
|
||||
dateTo: { $gte: startDate }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (existingLeave) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "You already have a pending leave request that overlaps with these dates"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for overlapping approved leaves
|
||||
const existingApprovedLeave = await LeavesModel.findOne({
|
||||
vaId,
|
||||
status: "APPROVED",
|
||||
$or: [
|
||||
{
|
||||
dateFrom: { $lte: endDate },
|
||||
dateTo: { $gte: startDate }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (existingApprovedLeave) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "You already have an approved leave that overlaps with these dates"
|
||||
});
|
||||
}
|
||||
|
||||
// Create leave request
|
||||
const leaveRequest = new LeavesModel({
|
||||
vaId,
|
||||
dateFrom: startDate,
|
||||
dateTo: endDate,
|
||||
days: totalDays.toString(),
|
||||
leaveType,
|
||||
comment,
|
||||
status: "PENDING"
|
||||
});
|
||||
|
||||
await leaveRequest.save();
|
||||
|
||||
// try {
|
||||
// const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/timesheet-adjustment`;
|
||||
// const sfData = {
|
||||
// "id": adjustmentRequest._id,
|
||||
// "va": {
|
||||
// "id": vaId
|
||||
// },
|
||||
// "project": {
|
||||
// "id": projectId
|
||||
// },
|
||||
// "date": adjustmentDate.toISOString().split('T')[0],
|
||||
// "loginTime": loginDateTime.toISOString(),
|
||||
// "logoutTime": logoutDateTime.toISOString(),
|
||||
// "comment": comment,
|
||||
// "status": "PENDING",
|
||||
// "rejectionReason": "",
|
||||
// "adjustedHours": formatTime(adjustedSeconds)
|
||||
// }
|
||||
|
||||
// const response = await fetch(salesforceUrl, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add any required headers for Salesforce authentication
|
||||
// // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
|
||||
// },
|
||||
// body: JSON.stringify({"data": sfData})
|
||||
// });
|
||||
|
||||
// if (!response.ok) {
|
||||
// const errorText = await response.text();
|
||||
// throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
|
||||
// }
|
||||
|
||||
// const result = await response.json();
|
||||
// // console.log("✅ Successfully synced VA data to Salesforce:", result);
|
||||
|
||||
// return res.status(201).json({
|
||||
// success: true,
|
||||
// message: "Adjustment request submitted successfully",
|
||||
// data: {
|
||||
// adjustmentId: adjustmentRequest._id,
|
||||
// date: adjustmentDate.toISOString().split('T')[0],
|
||||
// loginTime: loginDateTime.toISOString(),
|
||||
// logoutTime: logoutDateTime.toISOString(),
|
||||
// adjustedHours: formatTime(adjustedSeconds),
|
||||
// comment: comment,
|
||||
// status: "PENDING"
|
||||
// },
|
||||
// SyncSalesforce: result
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Failed to sync with Salesforce:", error.message);
|
||||
// console.log(error)
|
||||
|
||||
// // Create a custom error to distinguish Salesforce errors
|
||||
// const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
|
||||
// salesforceError.isSalesforceError = true;
|
||||
// throw salesforceError;
|
||||
// }
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: "Leave request submitted successfully",
|
||||
data: {
|
||||
leaveId: leaveRequest._id,
|
||||
dateFrom: startDate.toISOString().split('T')[0],
|
||||
dateTo: endDate.toISOString().split('T')[0],
|
||||
totalDays: totalDays,
|
||||
leaveType,
|
||||
comment,
|
||||
status: "PENDING"
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Leave apply error:", error);
|
||||
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ success: false, message: "Invalid token" });
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ success: false, message: "Token expired" });
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal Server Error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { va_leave_apply_function };
|
||||
|
|
@ -1,6 +1,130 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { WorkTrackModel, VAModel, ProjectModel, LoggedModel } from "../../../models/vaModel.js";
|
||||
|
||||
// Helper function to get start and end of day
|
||||
function getDayRange(date = new Date()) {
|
||||
const start = new Date(date);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const end = new Date(date);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function formatHoursToReadable(hours) {
|
||||
const wholeHours = Math.floor(hours);
|
||||
const decimalMinutes = (hours - wholeHours) * 60;
|
||||
const wholeMinutes = Math.round(decimalMinutes);
|
||||
|
||||
if (wholeHours > 0 && wholeMinutes > 0) {
|
||||
return `${wholeHours}h ${wholeMinutes}m`;
|
||||
} else if (wholeHours > 0) {
|
||||
return `${wholeHours}h`;
|
||||
} else {
|
||||
return `${wholeMinutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check daily hours and create logout log if needed
|
||||
async function checkDailyHoursAndLogout(vaId, vaEmail, projectId, project, currentSessionId = null, additionalTimeMs = 0) {
|
||||
try {
|
||||
const today = new Date();
|
||||
const { start, end } = getDayRange(today);
|
||||
|
||||
// Get today's work entries for this VA and project
|
||||
// Exclude the current session if it's already in the database
|
||||
const query = {
|
||||
vaId,
|
||||
projectId,
|
||||
from: { $gte: start, $lte: end }
|
||||
};
|
||||
|
||||
if (currentSessionId) {
|
||||
query._id = { $ne: currentSessionId };
|
||||
}
|
||||
|
||||
const todayWorkEntries = await WorkTrackModel.find(query);
|
||||
|
||||
// Calculate total hours worked today
|
||||
let totalTodayMs = todayWorkEntries.reduce((total, entry) => {
|
||||
return total + (entry.totalTime || 0);
|
||||
}, 0);
|
||||
|
||||
// Add the additional time from current session if provided
|
||||
totalTodayMs += additionalTimeMs;
|
||||
|
||||
const totalTodayHours = totalTodayMs / (1000 * 60 * 60);
|
||||
|
||||
// Check if daily dedicated hours are completed
|
||||
if (project.dedicatedHours > 0 && totalTodayHours >= project.dedicatedHours) {
|
||||
// Create automatic logout log
|
||||
const logoutLog = await LoggedModel.create({
|
||||
loggedType: "LOGOUT",
|
||||
vaId,
|
||||
vaEmail,
|
||||
projectId
|
||||
});
|
||||
|
||||
try {
|
||||
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
|
||||
const sfDataSync = {
|
||||
"_id": logoutLog._id,
|
||||
"loggedType": "LOGOUT",
|
||||
"vaId": vaId,
|
||||
"vaEmail": vaEmail,
|
||||
"projectId": projectId,
|
||||
"time": logoutLog.time,
|
||||
}
|
||||
|
||||
const response = await fetch(salesforceUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add any required headers for Salesforce authentication
|
||||
// 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
|
||||
},
|
||||
body: JSON.stringify(sfDataSync)
|
||||
});
|
||||
|
||||
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 {
|
||||
shouldLogout: true,
|
||||
totalTodayHours: totalTodayHours,
|
||||
dedicatedHours: project.dedicatedHours,
|
||||
logoutLogId: logoutLog._id,
|
||||
SyncSalesforce: result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to sync with Salesforce:", error.message);
|
||||
console.log(error)
|
||||
|
||||
// Create a custom error to distinguish Salesforce errors
|
||||
const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
|
||||
salesforceError.isSalesforceError = true;
|
||||
throw salesforceError;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shouldLogout: false,
|
||||
totalTodayHours: totalTodayHours,
|
||||
dedicatedHours: project.dedicatedHours
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error in checkDailyHoursAndLogout:", error);
|
||||
return { shouldLogout: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Getting logged hours in a specific project of VA
|
||||
async function va_log_track_work_callback(req, res) {
|
||||
try {
|
||||
|
|
@ -50,6 +174,16 @@ async function va_log_track_work_callback(req, res) {
|
|||
|
||||
// Handle POST → create entry
|
||||
if (req.method === "POST") {
|
||||
const dailyCheck = await checkDailyHoursAndLogout(vaId, vaEmail, projectId, project);
|
||||
|
||||
if (dailyCheck.shouldLogout) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Cannot start work - daily hours completed (${formatHoursToReadable(dailyCheck.totalTodayHours)} / ${formatHoursToReadable(dailyCheck.dedicatedHours)})`,
|
||||
autoLogout: true
|
||||
});
|
||||
}
|
||||
|
||||
const createdTrack = await WorkTrackModel.create({
|
||||
vaId,
|
||||
projectId,
|
||||
|
|
@ -63,6 +197,11 @@ async function va_log_track_work_callback(req, res) {
|
|||
success: true,
|
||||
message: "Work tracking started",
|
||||
data: createdTrack,
|
||||
dailyHours: {
|
||||
todayTotal: formatHoursToReadable(dailyCheck.totalTodayHours),
|
||||
dailyLimit: formatHoursToReadable(dailyCheck.dedicatedHours),
|
||||
remaining: formatHoursToReadable(dailyCheck.dedicatedHours - dailyCheck.totalTodayHours)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -89,11 +228,14 @@ async function va_log_track_work_callback(req, res) {
|
|||
|
||||
const formattedTotalTime = `${hours}h ${minutes}m ${seconds}s`;
|
||||
|
||||
// Check if daily dedicated hours are completed after this work session
|
||||
const dailyCheck = await checkDailyHoursAndLogout(vaId, vaEmail, projectId, project, id, totalTimeMs);
|
||||
|
||||
workEntry.to = toTime;
|
||||
workEntry.totalTime = totalTimeMs; // store as number of minutes
|
||||
await workEntry.save();
|
||||
|
||||
return res.status(200).json({
|
||||
const response = {
|
||||
success: true,
|
||||
message: "Work tracking stopped",
|
||||
data: {
|
||||
|
|
@ -101,9 +243,24 @@ async function va_log_track_work_callback(req, res) {
|
|||
from: workEntry.from,
|
||||
to: workEntry.to,
|
||||
formattedTotalTime,
|
||||
totalTimeMs
|
||||
totalTimeMs,
|
||||
sessionHours: totalTimeMs / (1000 * 60 * 60)
|
||||
},
|
||||
});
|
||||
dailyHours: {
|
||||
todayTotal: formatHoursToReadable(dailyCheck.totalTodayHours),
|
||||
dailyLimit: formatHoursToReadable(dailyCheck.dedicatedHours),
|
||||
remaining: formatHoursToReadable(Math.max(0, dailyCheck.dedicatedHours - dailyCheck.totalTodayHours))
|
||||
}
|
||||
};
|
||||
|
||||
// If auto-logout was triggered, add that info to response
|
||||
if (dailyCheck.shouldLogout) {
|
||||
response.autoLogout = true;
|
||||
response.message = `Work tracking stopped. Auto-logout created - daily hours completed (${formatHoursToReadable(dailyCheck.totalTodayHours)} / ${formatHoursToReadable(dailyCheck.dedicatedHours)})`;
|
||||
response.logoutLogId = dailyCheck.logoutLogId;
|
||||
}
|
||||
|
||||
return res.status(200).json(response);
|
||||
}
|
||||
|
||||
// Fallback for unsupported methods
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ async function va_register(req, res) {
|
|||
// Send data to Salesforce API after successful registration
|
||||
try {
|
||||
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/virtualassistant`;
|
||||
|
||||
delete created.userDetails._id;
|
||||
created.userDetails._id = created._id; // Include VA's MongoDB ID
|
||||
const response = await fetch(salesforceUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,236 @@
|
|||
import { LoggedModel } from "../../../models/vaModel.js";
|
||||
import { LoggedModel, WorkTrackModel, ProjectModel } from "../../../models/vaModel.js";
|
||||
|
||||
// Helper function to get start and end of day
|
||||
function getDayRange(date = new Date()) {
|
||||
const start = new Date(date);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const end = new Date(date);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function formatHoursToReadable(hours) {
|
||||
const wholeHours = Math.floor(hours);
|
||||
const decimalMinutes = (hours - wholeHours) * 60;
|
||||
const wholeMinutes = Math.round(decimalMinutes);
|
||||
|
||||
if (wholeHours > 0 && wholeMinutes > 0) {
|
||||
return `${wholeHours}h ${wholeMinutes}m`;
|
||||
} else if (wholeHours > 0) {
|
||||
return `${wholeHours}h`;
|
||||
} else {
|
||||
return `${wholeMinutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// function to close open work tracks and create logout
|
||||
async function closePreviousSession(vaId, lastLog) {
|
||||
try {
|
||||
const currentTime = new Date();
|
||||
|
||||
// Find all open work tracks for this VA and project (where to is not set or totalTime is 0)
|
||||
const openWorkTracks = await WorkTrackModel.find({
|
||||
vaId,
|
||||
logId: lastLog._id,
|
||||
$or: [
|
||||
{ to: { $exists: false } },
|
||||
{ totalTime: 0 },
|
||||
{ to: null }
|
||||
]
|
||||
});
|
||||
|
||||
// console.log(`Found ${openWorkTracks.length} open work tracks to close`);
|
||||
|
||||
// Close all open work tracks
|
||||
for (const workTrack of openWorkTracks) {
|
||||
const fromTime = new Date(workTrack.from);
|
||||
const totalTimeMs = currentTime - fromTime;
|
||||
|
||||
// Update the work track with end time and total time
|
||||
workTrack.to = currentTime;
|
||||
workTrack.totalTime = totalTimeMs;
|
||||
await workTrack.save();
|
||||
|
||||
// console.log(`Closed work track ${workTrack._id} with duration: ${totalTimeMs}ms`);
|
||||
}
|
||||
|
||||
// Create a logout log entry
|
||||
const logoutLog = await LoggedModel.create({
|
||||
loggedType: "LOGOUT",
|
||||
vaId,
|
||||
vaEmail: lastLog.vaEmail, // Use email from previous log
|
||||
projectId: lastLog.projectId,
|
||||
time: currentTime // Use same timestamp for consistent logging
|
||||
});
|
||||
|
||||
// console.log(`Created logout log: ${logoutLog._id}`);
|
||||
try {
|
||||
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/va-project-log`;
|
||||
const sfDataSync = {
|
||||
"_id": logoutLog._id,
|
||||
"loggedType": "LOGOUT",
|
||||
"vaId": vaId,
|
||||
"vaEmail": lastLog.vaEmail,
|
||||
"projectId": lastLog.projectId,
|
||||
"time": logoutLog.time,
|
||||
}
|
||||
|
||||
const response = await fetch(salesforceUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add any required headers for Salesforce authentication
|
||||
// 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
|
||||
},
|
||||
body: JSON.stringify(sfDataSync)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// console.log("✅ Successfully synced VA data to Salesforce:", result);
|
||||
|
||||
return {
|
||||
closedWorkTracks: openWorkTracks.length,
|
||||
logoutLogId: logoutLog._id,
|
||||
SyncSalesforce: result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to sync with Salesforce:", error.message);
|
||||
console.log(error)
|
||||
|
||||
// Create a custom error to distinguish Salesforce errors
|
||||
const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
|
||||
salesforceError.isSalesforceError = true;
|
||||
throw salesforceError;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error closing previous session:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Getting logged hours in a specific project of VA
|
||||
async function va_project_log_callback(req, res) {
|
||||
try {
|
||||
const payload = req.body || {};
|
||||
const { vaId, vaEmail, projectId, type } = payload;
|
||||
|
||||
|
||||
if (!vaId, !vaEmail, !projectId, !type) return res.status(400).send("Missing Props");
|
||||
|
||||
const project = await ProjectModel.findById(projectId);
|
||||
if (!project) {
|
||||
return res.status(400).json({ success: false, message: "Project not found" });
|
||||
}
|
||||
|
||||
// For LOGIN requests, check daily hours
|
||||
if (type === "LOGIN") {
|
||||
const { start, end } = getDayRange();
|
||||
// Get today's total hours
|
||||
const todayWorkEntries = await WorkTrackModel.find({
|
||||
vaId,
|
||||
projectId,
|
||||
from: { $gte: start, $lte: end }
|
||||
});
|
||||
|
||||
const totalTodayMs = todayWorkEntries.reduce((total, entry) => {
|
||||
return total + (entry.totalTime || 0);
|
||||
}, 0);
|
||||
|
||||
const totalTodayHours = totalTodayMs / (1000 * 60 * 60);
|
||||
|
||||
// Check if daily hours are already completed
|
||||
if (project.dedicatedHours > 0 && totalTodayHours >= project.dedicatedHours) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Cannot login - daily hours already completed (${formatHoursToReadable(totalTodayHours)} / ${formatHoursToReadable(project.dedicatedHours)})`
|
||||
});
|
||||
}
|
||||
|
||||
// Check last log entry
|
||||
const lastLog = await LoggedModel.findOne({ vaId })
|
||||
.sort({ time: -1 })
|
||||
.lean();
|
||||
|
||||
// Prevent login if last action was login (not logout)
|
||||
if (lastLog && lastLog.loggedType === "LOGIN") {
|
||||
|
||||
// check last workModel login time and make a logout request then create login
|
||||
// console.log(`User already logged in. Closing previous session...`);
|
||||
const closeResult = await closePreviousSession(vaId, lastLog);
|
||||
|
||||
// console.log(`Previous session closed: ${closeResult.closedWorkTracks} work tracks, logout log: ${closeResult.logoutLogId}`);
|
||||
|
||||
// Now proceed to create new login
|
||||
const createdLog = await LoggedModel.create({
|
||||
loggedType: "LOGIN",
|
||||
vaId,
|
||||
vaEmail,
|
||||
projectId
|
||||
});
|
||||
|
||||
return res.status(201).send({
|
||||
success: true,
|
||||
message: `Login Successful (previous session closed)`,
|
||||
logId: createdLog._id,
|
||||
previousSession: {
|
||||
closedWorkTracks: closeResult.closedWorkTracks,
|
||||
logoutLogId: closeResult.logoutLogId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createdLog = await LoggedModel.create({ loggedType: type, vaId, vaEmail, projectId })
|
||||
// console.log(createdLog)
|
||||
// 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 fetch(salesforceUrl, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add any required headers for Salesforce authentication
|
||||
// // 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
|
||||
// },
|
||||
// body: JSON.stringify(sfData)
|
||||
// });
|
||||
|
||||
// if (!response.ok) {
|
||||
// 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, message: `${type} Successfully`, logId: createdLog._id, SyncSalesforce: result });
|
||||
// } catch (error) {
|
||||
// console.error("Failed to sync with Salesforce:", error.message);
|
||||
// console.log(error)
|
||||
|
||||
// // Create a custom error to distinguish Salesforce errors
|
||||
// const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
|
||||
// salesforceError.isSalesforceError = true;
|
||||
// throw salesforceError;
|
||||
// }
|
||||
|
||||
return res.status(201).send({ success: true, message: `${type} Successfully`, logId: createdLog._id });
|
||||
|
||||
} catch (error) {
|
||||
return res.status(500).send("Internal Server Error");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,21 @@ function formatHMS(ms) {
|
|||
return `${h}h ${m}m ${s}s`;
|
||||
}
|
||||
|
||||
function formatHoursToReadable(hours) {
|
||||
const wholeHours = Math.floor(hours);
|
||||
const decimalMinutes = (hours - wholeHours) * 60;
|
||||
const wholeMinutes = Math.round(decimalMinutes);
|
||||
|
||||
if (wholeHours > 0 && wholeMinutes > 0) {
|
||||
return `${wholeHours}h ${wholeMinutes}m`;
|
||||
} else if (wholeHours > 0) {
|
||||
return `${wholeHours}h`;
|
||||
} else {
|
||||
return `${wholeMinutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function va_projects_log_list_function(req, res) {
|
||||
try {
|
||||
const { projectId, from, to } = req.params;
|
||||
|
|
@ -101,7 +116,9 @@ async function va_projects_log_list_function(req, res) {
|
|||
activityStatus,
|
||||
totalWorkTime: formatHMS(totalWorkMs),
|
||||
totalWorkMs,
|
||||
totalLogs: logs.length
|
||||
dedicatedHours: formatHoursToReadable(project?.dedicatedHours),
|
||||
totalLogs: logs.length,
|
||||
status: project?.status
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,19 +140,80 @@ async function va_timesheet_adjustment_function(req, res) {
|
|||
return `${h}h ${m}m ${s}s`;
|
||||
};
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: "Adjustment request submitted successfully",
|
||||
data: {
|
||||
adjustmentId: adjustmentRequest._id,
|
||||
date: adjustmentDate.toISOString().split('T')[0],
|
||||
loginTime: loginDateTime.toISOString(),
|
||||
logoutTime: logoutDateTime.toISOString(),
|
||||
adjustedHours: formatTime(adjustedSeconds),
|
||||
comment: comment,
|
||||
status: "PENDING"
|
||||
try {
|
||||
const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/timesheet-adjustment`;
|
||||
const sfData = {
|
||||
"id": adjustmentRequest._id,
|
||||
"va": {
|
||||
"id": vaId
|
||||
},
|
||||
"project": {
|
||||
"id": projectId
|
||||
},
|
||||
"date": adjustmentDate.toISOString().split('T')[0],
|
||||
"loginTime": loginDateTime.toISOString(),
|
||||
"logoutTime": logoutDateTime.toISOString(),
|
||||
"comment": comment,
|
||||
"status": "PENDING",
|
||||
"rejectionReason": "",
|
||||
"adjustedHours": formatTime(adjustedSeconds)
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(salesforceUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add any required headers for Salesforce authentication
|
||||
// 'Authorization': 'Bearer ' + process.env.SF_ACCESS_TOKEN
|
||||
},
|
||||
body: JSON.stringify({"data": sfData})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// console.log("✅ Successfully synced VA data to Salesforce:", result);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: "Adjustment request submitted successfully",
|
||||
data: {
|
||||
adjustmentId: adjustmentRequest._id,
|
||||
date: adjustmentDate.toISOString().split('T')[0],
|
||||
loginTime: loginDateTime.toISOString(),
|
||||
logoutTime: logoutDateTime.toISOString(),
|
||||
adjustedHours: formatTime(adjustedSeconds),
|
||||
comment: comment,
|
||||
status: "PENDING"
|
||||
},
|
||||
SyncSalesforce: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to sync with Salesforce:", error.message);
|
||||
console.log(error)
|
||||
|
||||
// Create a custom error to distinguish Salesforce errors
|
||||
const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
|
||||
salesforceError.isSalesforceError = true;
|
||||
throw salesforceError;
|
||||
}
|
||||
|
||||
// return res.status(201).json({
|
||||
// success: true,
|
||||
// message: "Adjustment request submitted successfully",
|
||||
// data: {
|
||||
// adjustmentId: adjustmentRequest._id,
|
||||
// date: adjustmentDate.toISOString().split('T')[0],
|
||||
// loginTime: loginDateTime.toISOString(),
|
||||
// logoutTime: logoutDateTime.toISOString(),
|
||||
// adjustedHours: formatTime(adjustedSeconds),
|
||||
// comment: comment,
|
||||
// status: "PENDING"
|
||||
// }
|
||||
// });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Timesheet adjustment error:", error);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ async function updateEntityRelationships(data) {
|
|||
clientId,
|
||||
{
|
||||
$addToSet: {
|
||||
projectIds: projectId
|
||||
projectIds: projectId,
|
||||
vaIds: vaId
|
||||
}
|
||||
},
|
||||
{ new: true }
|
||||
|
|
@ -37,21 +38,46 @@ async function updateEntityRelationships(data) {
|
|||
{ _id: { $in: vaId } },
|
||||
{
|
||||
$addToSet: {
|
||||
projectIds: projectId
|
||||
projectIds: projectId,
|
||||
clientIds: clientId
|
||||
}
|
||||
}
|
||||
);
|
||||
results.vaUpdate = `Updated ${vaUpdateResult.modifiedCount} VAs`;
|
||||
}
|
||||
|
||||
// Update Project with clientId and vaId
|
||||
// // Update Project with clientId and vaId
|
||||
// const projectUpdateData = {};
|
||||
// if (clientId) {
|
||||
// projectUpdateData.clientId = clientId;
|
||||
// }
|
||||
|
||||
// // 🐞🐞🐞 Here Duplicate Entry of Va is Created if SF pass same va multiple times in same req
|
||||
// if (vaId && Array.isArray(vaId)) {
|
||||
// projectUpdateData.vaAssigned = vaId;
|
||||
// }
|
||||
|
||||
// if (Object.keys(projectUpdateData).length > 0) {
|
||||
// const projectUpdate = await ProjectModel.findByIdAndUpdate(
|
||||
// projectId,
|
||||
// projectUpdateData,
|
||||
// { new: true }
|
||||
// );
|
||||
// results.projectUpdate = projectUpdate ? 'Project updated successfully' : 'Project not found';
|
||||
// }
|
||||
|
||||
if (vaId && Array.isArray(vaId)) {
|
||||
await ProjectModel.findByIdAndUpdate(
|
||||
projectId,
|
||||
{ $addToSet: { vaAssigned: { $each: vaId } } }
|
||||
);
|
||||
}
|
||||
|
||||
// Update other fields normally
|
||||
const projectUpdateData = {};
|
||||
if (clientId) {
|
||||
projectUpdateData.clientId = clientId;
|
||||
}
|
||||
if (vaId && Array.isArray(vaId)) {
|
||||
projectUpdateData.vaAssigned = vaId;
|
||||
}
|
||||
|
||||
if (Object.keys(projectUpdateData).length > 0) {
|
||||
const projectUpdate = await ProjectModel.findByIdAndUpdate(
|
||||
|
|
@ -202,16 +228,46 @@ async function removeEntityRelationships(data) {
|
|||
if (clientId) {
|
||||
await ClientModel.findByIdAndUpdate(
|
||||
clientId,
|
||||
{ $pull: { projectIds: projectId } }
|
||||
{$pull: {
|
||||
projectIds: projectId,
|
||||
vaIds: vaId
|
||||
}}
|
||||
);
|
||||
}
|
||||
|
||||
// Remove projectId from VAs
|
||||
if (vaId && Array.isArray(vaId)) {
|
||||
await VAModel.updateMany(
|
||||
{ _id: { $in: vaId } },
|
||||
{ $pull: { projectIds: projectId } }
|
||||
);
|
||||
// await VAModel.updateMany(
|
||||
// { _id: { $in: vaId } },
|
||||
// { $pull: {
|
||||
// projectIds: projectId,
|
||||
// // 🐞🐞🐞 Here before removing we need to check if VA is still assigned to other projects of the same client
|
||||
// clientIds: clientId
|
||||
// }}
|
||||
// );
|
||||
for (const va of vaId) {
|
||||
// Check if VA is still assigned to other projects of the same client
|
||||
const otherProjectsWithSameClient = await ProjectModel.countDocuments({
|
||||
_id: { $ne: projectId },
|
||||
clientId: clientId,
|
||||
vaAssigned: va
|
||||
});
|
||||
|
||||
// Prepare update operation
|
||||
const updateOperation = {
|
||||
$pull: { projectIds: projectId }
|
||||
};
|
||||
|
||||
// Only remove clientId if VA is not assigned to any other projects from this client
|
||||
if (!otherProjectsWithSameClient) {
|
||||
updateOperation.$pull = {
|
||||
...updateOperation.$pull,
|
||||
clientIds: clientId
|
||||
};
|
||||
}
|
||||
|
||||
await VAModel.findByIdAndUpdate(va, updateOperation);
|
||||
}
|
||||
}
|
||||
|
||||
results.projectRemoval = 'Project relationships removed successfully';
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ const projectSchema = new Schema({
|
|||
},
|
||||
],
|
||||
dedicatedHours: {
|
||||
type: Number,
|
||||
default: 0
|
||||
type: String,
|
||||
default: "0"
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
|
|
@ -311,8 +311,8 @@ const vaWorkTrackSchema = new Schema({
|
|||
ref: "Project",
|
||||
},
|
||||
logId: {
|
||||
type: String,
|
||||
ref: "Logged",
|
||||
type: Types.ObjectId,
|
||||
ref: "TrackLog",
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
|
|
@ -424,6 +424,51 @@ const adjustedHoursSchema = new Schema({
|
|||
}]
|
||||
}, { timestamps: true });
|
||||
|
||||
const leavesSchema = new Schema({
|
||||
vaId: {
|
||||
type: Types.ObjectId,
|
||||
ref: "VA",
|
||||
required: true
|
||||
},
|
||||
clientId: {
|
||||
type: Types.ObjectId,
|
||||
ref: "Client"
|
||||
},
|
||||
dateFrom: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
dateTo: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
days: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
comment: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
leaveType: {
|
||||
type: String,
|
||||
enum: ["SICK", "PTO"],
|
||||
default: "SICK",
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["PENDING", "APPROVED", "REJECTED"],
|
||||
default: "PENDING"
|
||||
},
|
||||
reviewedAt: {
|
||||
type: Date
|
||||
},
|
||||
rejectionReason: {
|
||||
type: String // If rejected, store the reason
|
||||
}
|
||||
}, { timestamps: true });
|
||||
|
||||
/* ---------------------- Model Exports ---------------------- */
|
||||
const VAModel = model("VA", vaSchema);
|
||||
const ClientModel = model("Client", clientSchema);
|
||||
|
|
@ -432,5 +477,6 @@ const LoggedModel = model("TrackLog", loggedSchema);
|
|||
const WorkTrackModel = model("TrackWork", vaWorkTrackSchema);
|
||||
const PasswordResetModel = model("PasswordReset", passwordResetSchema);
|
||||
const AdjustedHoursModel = model("AdjustedHours", adjustedHoursSchema);
|
||||
const LeavesModel = model("Leaves", leavesSchema);
|
||||
|
||||
export { VAModel, ClientModel, ProjectModel, LoggedModel, WorkTrackModel, PasswordResetModel, AdjustedHoursModel };
|
||||
export { VAModel, ClientModel, ProjectModel, LoggedModel, WorkTrackModel, PasswordResetModel, AdjustedHoursModel, LeavesModel };
|
||||
|
|
@ -9,13 +9,16 @@ import { client_refresh_token_callback } from "../../controllers/client/clientRe
|
|||
import { client_request_password_reset } from "../../controllers/passwordReset.controllers.js";
|
||||
import { clientAuthValidator } from "../../middlewares/auth.middleware.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_dashboard_projects } from "../../controllers/client/dashboard/clientDashboardProjects.controller.js"
|
||||
|
||||
// clients routes
|
||||
clientRoutes.post("/register", va_register_client); // Register a new VA
|
||||
clientRoutes.post("/login", va_login_client); // Login Client
|
||||
clientRoutes.get("/refreshToken", client_refresh_token_callback); // Client Refresh Token
|
||||
clientRoutes.post("/forgot-password", client_request_password_reset);
|
||||
clientRoutes.post("/verify-client-email", client_verify_email);
|
||||
|
||||
// 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)
|
||||
|
|
@ -24,7 +27,8 @@ clientRoutes.get("/:clientId/projects/:projectId", va_clients_projects_callback)
|
|||
|
||||
// dashboard API's
|
||||
clientRoutes.get("/dashboard", clientAuthValidator, client_dashboard); // VA Clients Projects Callback
|
||||
clientRoutes.post("/dashboard/va-report", clientAuthValidator, client_dashboard_va_log); // VA Clients Projects Callback
|
||||
clientRoutes.post("/dashboard/va-report", clientAuthValidator, client_dashboard_va_log); // VA details
|
||||
clientRoutes.get("/dashboard-projects", clientAuthValidator, client_dashboard_projects); // VA Clients Projects Callback
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ import { va_timesheet_summary_function } from "../../controllers/va/vaTimesheetA
|
|||
import { va_timesheet_adjustment_function } from "../../controllers/va/vaTimesheetAdjustment/vaTimesheetAdjustment.controller.js";
|
||||
import { get_adjustment_requests_list } from "../../controllers/va/vaTimesheetAdjustment/vaAllTimesheetAdjustmentReuqest.controller.js"
|
||||
import { va_timesheet_manage_adjustment_request } from "../../controllers/va/vaTimesheetAdjustment/vaManageTimesheetAdjustment.controller.js"
|
||||
import { va_verify_email } from "../../controllers/va/vaAuth/vaVerifyEmail.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_manage_function } from "../../controllers/va/vaLeaves/vaLeaveManage.controller.js";
|
||||
|
||||
vaRoutes.get("/", va_callback); // VA Callback for getting active VA
|
||||
|
||||
|
|
@ -30,6 +34,7 @@ vaRoutes.get("/logout", authValidator, va_logout_callback); // VA Callback for l
|
|||
vaRoutes.post("/refreshToken", va_refresh_token_callback); // VA Callback for refresh token
|
||||
vaRoutes.post("/register", va_register); // Register a new VA
|
||||
vaRoutes.post("/forgot-password", va_request_password_reset);
|
||||
vaRoutes.post("/verify-va-email", va_verify_email);
|
||||
|
||||
vaRoutes.get("/profile", authValidator, va_profile_callback); // Get VA Profile
|
||||
vaRoutes.patch("/profile", authValidator, va_profile_update_callback); // Update VA Profile
|
||||
|
|
@ -41,9 +46,17 @@ vaRoutes.get("/:userName", va_username_callback); // VA Username Callback
|
|||
// 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.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/:status?/:projectId?/:vaId?", get_adjustment_requests_list); // show all adjustment list to approve and reject
|
||||
vaRoutes.patch("/timesheet/adjustments/:adjustmentId", va_timesheet_manage_adjustment_request);
|
||||
|
||||
// Leaves routes
|
||||
vaRoutes.get("/leaves/all", authValidator, va_leave_list_function); // Get all leaves
|
||||
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/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.patch("/leave/:leaveId", va_leave_manage_function);
|
||||
|
||||
// timesheet adjustment routes
|
||||
|
||||
// Example: GET /va/search?name=john&email=@example.com&from=2025-01-01&to=2025-01-31 (NC)
|
||||
|
|
|
|||
399
src/server.js
399
src/server.js
|
|
@ -13,16 +13,32 @@ import cors from 'cors';
|
|||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
|
||||
import apiRoutes from './routes/index.js'
|
||||
|
||||
|
||||
import { render_reset_form } from "./controllers/passwordReset.controllers.js";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const app = express();
|
||||
|
||||
|
||||
const server = new createServer(app);
|
||||
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) => {
|
||||
console.log("⚠️ Certificate error:", url);
|
||||
|
||||
|
|
@ -70,7 +86,6 @@ app.get("/download-file", (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
|
||||
app.get('/varegister/:sf_token?', (req, res) => {
|
||||
const { sf_token } = req.params;
|
||||
|
||||
|
|
@ -94,10 +109,18 @@ app.get('/varegister/:sf_token?', (req, res) => {
|
|||
});
|
||||
|
||||
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);
|
||||
|
||||
// const { sf_token } = useParams();
|
||||
|
||||
res.render('va_client_register', { title: "Client Registration" });
|
||||
if (decoded) {
|
||||
res.render('va_client_register', { title: "Client Registration" });
|
||||
} else {
|
||||
res.render('not_found', { title: "Client Registration" });
|
||||
}
|
||||
} catch (error) {
|
||||
res.render('not_found', { title: "Client Registration" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/auth/password/reset-form', render_reset_form);
|
||||
|
|
@ -136,35 +159,38 @@ app.get('/client/virtual-assistants', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// app.get('/client/timesheets', async (req, res) => {
|
||||
// try {
|
||||
// const timesheetData = await timesheetService.getClientTimesheets(req.user.id);
|
||||
// res.render('client/dashboard-timesheets', timesheetData);
|
||||
// } catch (error) {
|
||||
// console.error('Timesheets error:', error);
|
||||
// res.status(500).render('client/dashboard-timesheets', { error: 'Failed to load timesheets' });
|
||||
// }
|
||||
// });
|
||||
app.get('/client/projects', async (req, res) => {
|
||||
try {
|
||||
// const vaData = await virtualAssistantService.getClientVAs(req.user.id);
|
||||
res.render('client/dashboard-projects');
|
||||
} catch (error) {
|
||||
console.error('Projects error:', error);
|
||||
res.status(500).render('client/dashboard-projects', { error: 'Failed to load virtual assistants' });
|
||||
}
|
||||
});
|
||||
|
||||
// app.get('/client/invoices', async (req, res) => {
|
||||
// try {
|
||||
// const invoiceData = await invoiceService.getClientInvoices(req.user.id);
|
||||
// res.render('client/dashboard-invoices', invoiceData);
|
||||
// } catch (error) {
|
||||
// console.error('Invoices error:', error);
|
||||
// res.status(500).render('client/dashboard-invoices', { error: 'Failed to load invoices' });
|
||||
// }
|
||||
// });
|
||||
app.get("/live/:projectId/:vaId", async (req, res) => {
|
||||
const { projectId, vaId } = req.params;
|
||||
|
||||
// app.get('/client/referrals', async (req, res) => {
|
||||
// try {
|
||||
// const referralData = await referralService.getClientReferrals(req.user.id);
|
||||
// res.render('client/dashboard-referrals', referralData);
|
||||
// } catch (error) {
|
||||
// console.error('Referrals error:', error);
|
||||
// res.status(500).render('client/dashboard-referrals', { error: 'Failed to load referrals' });
|
||||
// }
|
||||
// });
|
||||
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 })
|
||||
})
|
||||
|
||||
app.get("/live/:projectId/:vaId", async (req, res) => {
|
||||
const { projectId, vaId } = req.params;
|
||||
|
|
@ -191,241 +217,17 @@ app.get("/live/:projectId/:vaId", async (req, res) => {
|
|||
|
||||
// Handling Socket
|
||||
import { VAModel, ProjectModel, ClientModel } from './models/vaModel.js';
|
||||
import { ObjectId } from 'mongodb/lib/bson.js';
|
||||
|
||||
|
||||
const vaSocketRespectToClient = new Map();
|
||||
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth?.token;
|
||||
const projectId = socket.handshake.auth?.projectId;
|
||||
const userType = socket.handshake.auth?.userType;
|
||||
|
||||
console.log("🔐 Received token:", token);
|
||||
|
||||
if (!token) {
|
||||
return next(new Error("Not Authorized: Missing Token"));
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET_ACCESS_TOKEN);
|
||||
|
||||
if (!decoded?.email) {
|
||||
return next(new Error("Invalid token payload"));
|
||||
}
|
||||
|
||||
/* ---------------------- 1⃣ Check if VA ---------------------- */
|
||||
let vaUser = null;
|
||||
let clientData = null;
|
||||
|
||||
if (userType === "va") {
|
||||
vaUser = await VAModel.findOne({ "userDetails.email": decoded.email });
|
||||
clientData = await ProjectModel.findById(projectId).populate("clientId");
|
||||
} else if (userType === "client") {
|
||||
clientData = await ProjectModel.findById(projectId).populate("clientId");
|
||||
}
|
||||
|
||||
if (clientData == null) return next(new Error("Invalid Project ID"));
|
||||
const vaProjClientId = clientData.clientId._id;
|
||||
|
||||
if (vaUser && vaUser.accessToken === token) {
|
||||
socket.data.role = "va";
|
||||
socket.data.vaId = vaUser._id.toString();
|
||||
socket.data.vaEmail = decoded.email;
|
||||
|
||||
const a = vaProjClientId.toString();
|
||||
const b = clientData.clientId._id.toString();
|
||||
const c = JSON.stringify(clientData);
|
||||
|
||||
console.log(a, b, c, "vaProjClientId vs clientData.clientId");
|
||||
|
||||
vaSocketRespectToClient.set(vaProjClientId.toString(), socket.id)
|
||||
|
||||
console.log("✅ Authenticated VA:", vaUser.userDetails.email);
|
||||
return next();
|
||||
}
|
||||
|
||||
/* ---------------------- 2⃣ Check if Client ---------------------- */
|
||||
// const clientUser = await ClientModel.findOne({ "userDetails.email": decoded.email });
|
||||
|
||||
// if (clientUser) {
|
||||
// socket.data.role = "client";
|
||||
// socket.data.clientId = clientUser._id.toString();
|
||||
|
||||
// // Get all VA IDs assigned to this client
|
||||
// socket.data.allowedVAs = clientUser.vaIds.map((va) => va.toString());
|
||||
|
||||
// console.log("✅ Authenticated Client:", clientUser.userDetails.email);
|
||||
// return next();
|
||||
// }
|
||||
const clientUser = clientData.clientId;
|
||||
|
||||
if (clientUser) {
|
||||
socket.data.role = "client";
|
||||
socket.data.clientId = clientUser._id.toString();
|
||||
|
||||
// Get all VA IDs assigned to this client
|
||||
socket.data.allowedVAs = clientData.vaAssigned.map((va) => va.toString());
|
||||
|
||||
// console.log("✅ Authenticated Client:", clientUser.userDetails.email);
|
||||
return next();
|
||||
}
|
||||
|
||||
return next(new Error("Not Authorized: Invalid User"));
|
||||
} catch (error) {
|
||||
console.error("❌ Socket Auth Error:", error.message);
|
||||
next(new Error("Authentication failed: " + error.message));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log("User connected:", socket.id);
|
||||
|
||||
// Register role and ID (client or VA)
|
||||
socket.on("register", async ({ role, id, projectId = null, clientId = null }) => {
|
||||
if (role === "va") {
|
||||
socket.data.role = "va";
|
||||
socket.data.vaId = id;
|
||||
console.log(`VA registered: ${id}`);
|
||||
}
|
||||
|
||||
if (role === "client") {
|
||||
socket.data.role = "client";
|
||||
socket.data.vaId = id;
|
||||
socket.data.projectId = projectId;
|
||||
socket.data.clientId = null
|
||||
|
||||
if (!projectId || !clientId) {
|
||||
console.log("projectId is missing in register payload");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.data.clientId = clientId;
|
||||
|
||||
// 🧩 Get allowed VAs for this client
|
||||
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 projectVAIds = new Set();
|
||||
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 ${id} can view VAs:`, socket.data.allowedVAs);
|
||||
}
|
||||
});
|
||||
|
||||
// When VA sends live image
|
||||
socket.on("sendImage", async ({ token, image, title }) => {
|
||||
try {
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET_ACCESS_TOKEN);
|
||||
const vaEmail = decoded.email;
|
||||
|
||||
// Find VA in DB
|
||||
const vaUser = await VAModel.findOne({ "userDetails.email": vaEmail });
|
||||
if (!vaUser) {
|
||||
console.log("❌ Unauthorized VA");
|
||||
return;
|
||||
}
|
||||
|
||||
const vaId = vaUser._id.toString();
|
||||
console.log("🟢 Received image 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("receiveImage", image, title);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Token verification failed:", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("selectApp", ({ appTitle, clientId }) => {
|
||||
console.log(`VA selected app: ${appTitle}`);
|
||||
|
||||
const sssrrrr = vaSocketRespectToClient.get(clientId);
|
||||
console.log(vaSocketRespectToClient.get(clientId), sssrrrr, "vaSocketRespectToClient[clientId]")
|
||||
// You can forward this info to the respective Electron client
|
||||
socket.to(vaSocketRespectToClient.get(clientId)).emit("appToCapture", { appTitle });
|
||||
});
|
||||
|
||||
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();
|
||||
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("receiveVisibleApps", apps);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Token verification failed:", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('a user Disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// io.on('connection', (socket) => {
|
||||
|
||||
// socket.on("sendImage", (base64String) => {
|
||||
// io.emit("receiveImage", base64String);
|
||||
// });
|
||||
|
||||
|
||||
// socket.on('disconnect', () => {
|
||||
// console.log('a user Disconnected');
|
||||
// });
|
||||
// });
|
||||
|
||||
app.get("/client/login", (req, res) => {
|
||||
res.render("client_login")
|
||||
})
|
||||
app.get("/client/forgot-password", (req, res) => {
|
||||
res.render("forgot_password")
|
||||
})
|
||||
|
||||
// Handling Socket
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth?.token;
|
||||
const projectId = socket.handshake.auth?.projectId;
|
||||
const userType = socket.handshake.auth?.userType;
|
||||
const vaId = socket.handshake.auth?.vaId;
|
||||
|
||||
|
||||
console.log("🔐 Received token:", token);
|
||||
|
||||
|
|
@ -464,7 +266,7 @@ io.use(async (socket, next) => {
|
|||
|
||||
console.log(a, b, c, "vaProjClientId vs clientData.clientId");
|
||||
|
||||
vaSocketRespectToClient.set(`${vaProjClientId.toString()}-${vaUser._id.toString()}`, socket.id)
|
||||
vaSocketRespectToClient.set(`${vaProjClientId.toString()}-${vaUser._id.toString()}-${projectId.toString()}-va`, socket.id)
|
||||
|
||||
console.log("✅ Authenticated VA:", vaUser.userDetails.email);
|
||||
return next();
|
||||
|
|
@ -491,6 +293,7 @@ io.use(async (socket, next) => {
|
|||
|
||||
// Get all VA IDs assigned to this client
|
||||
socket.data.allowedVAs = clientData.vaAssigned.map((va) => va.toString());
|
||||
vaSocketRespectToClient.set(`${vaProjClientId.toString()}-${vaId.toString()}-${projectId.toString()}-client`, socket.id)
|
||||
|
||||
// console.log("✅ Authenticated Client:", clientUser.userDetails.email);
|
||||
return next();
|
||||
|
|
@ -503,8 +306,6 @@ io.use(async (socket, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log("User connected:", socket.id);
|
||||
|
||||
|
|
@ -539,7 +340,7 @@ io.on("connection", (socket) => {
|
|||
});
|
||||
|
||||
// When VA sends live image
|
||||
socket.on("sendImage", async ({ token, image, title }) => {
|
||||
socket.on("sendImage", async ({ token, image, title, projectId }) => {
|
||||
try {
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET_ACCESS_TOKEN);
|
||||
|
|
@ -552,30 +353,42 @@ io.on("connection", (socket) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const project = await ProjectModel.findById(projectId);
|
||||
if (!project) {
|
||||
console.log("❌ Invalid Project ID");
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = project.clientId.toString();
|
||||
const vaId = vaUser._id.toString();
|
||||
const clientVaUniqueId = `${clientId}-${vaId}-${projectId}-client`;
|
||||
const socketId = vaSocketRespectToClient.get(clientVaUniqueId);
|
||||
|
||||
console.log("🟢 Received image 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("receiveImage", image, title);
|
||||
}
|
||||
}
|
||||
// for (let [_, clientSocket] of io.sockets.sockets) {
|
||||
// if (
|
||||
// clientSocket.data.role === "client" &&
|
||||
// clientSocket.data.allowedVAs?.includes(vaId)
|
||||
// ) {
|
||||
// clientSocket.emit("receiveImage", image, title);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Can be like clientId-projectId-vaId to uniquely identify
|
||||
socket.to(socketId).emit("receiveImage", image, title);
|
||||
} catch (err) {
|
||||
console.error("❌ Token verification failed:", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("selectApp", ({ appTitle, clientId }) => {
|
||||
socket.on("selectApp", ({ appTitle, clientId, vaId, projectId }) => {
|
||||
console.log(`VA selected app: ${appTitle}`);
|
||||
|
||||
const sssrrrr = vaSocketRespectToClient.get(`${clientId}-${socket.data.vaId}`);
|
||||
console.log(vaSocketRespectToClient.get(`${clientId}-${socket.data.vaId}`), sssrrrr, "vaSocketRespectToClient[clientId]")
|
||||
console.log(vaSocketRespectToClient.get(`${clientId}-${vaId}-${projectId}-va`), "vaSocketRespectToClient[clientId]")
|
||||
// You can forward this info to the respective Electron client
|
||||
socket.to(vaSocketRespectToClient.get(`${clientId}-${socket.data.vaId}`)).emit("appToCapture", { appTitle });
|
||||
socket.to(vaSocketRespectToClient.get(`${clientId}-${vaId}-${projectId}-va`)).emit("appToCapture", { appTitle });
|
||||
});
|
||||
|
||||
socket.on("getVisibleApps", async ({ token, apps }) => {
|
||||
|
|
@ -600,7 +413,7 @@ io.on("connection", (socket) => {
|
|||
clientSocket.data.role === "client" &&
|
||||
clientSocket.data.allowedVAs?.includes(vaId)
|
||||
) {
|
||||
clientSocket.emit("receiveVisibleApps", apps);
|
||||
clientSocket.emit("receiveVisibleApps", { apps, vaId });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -614,42 +427,10 @@ io.on("connection", (socket) => {
|
|||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// io.on('connection', (socket) => {
|
||||
|
||||
// socket.on("sendImage", (base64String) => {
|
||||
// io.emit("receiveImage", base64String);
|
||||
// });
|
||||
|
||||
|
||||
// socket.on('disconnect', () => {
|
||||
// console.log('a user Disconnected');
|
||||
// });
|
||||
// });
|
||||
|
||||
|
||||
|
||||
// Express App Listening
|
||||
// app.listen(PORT, () => {
|
||||
// mongoose
|
||||
// .connect(
|
||||
// process.env.MONGO_URI,
|
||||
// )
|
||||
// .then(() => console.log("mongodb connected"))
|
||||
// .catch((err) => {
|
||||
// console.log(err.message);
|
||||
// process.exit(1);
|
||||
// });
|
||||
// console.log(`Express started on http://localhost:${PORT}`);
|
||||
// });
|
||||
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).render("not_found", {
|
||||
title: "404 - Page Not Found"
|
||||
});
|
||||
res.status(404).render("not_found", {
|
||||
title: "404 - Page Not Found"
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ class PasswordResetService {
|
|||
if (!user) {
|
||||
logger.info(`Password reset requested for non-existent ${userType}: ${email}`);
|
||||
return {
|
||||
success: true,
|
||||
message: "If the email exists, a reset link has been sent"
|
||||
success: false,
|
||||
message: "Email not recognized. Please sign up to continue."
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ class SalesforceService {
|
|||
const { userDetails } = clientData;
|
||||
|
||||
return {
|
||||
_id: _id,
|
||||
userDetails: {
|
||||
_id: _id,
|
||||
name: userDetails.name,
|
||||
email: userDetails.email.toLowerCase(),
|
||||
address: userDetails.address || null,
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ class VaClientService {
|
|||
|
||||
// Sanitize and prepare data
|
||||
const clientData = this.sanitizeClientData(data);
|
||||
|
||||
// Send data to Salesforce first
|
||||
await this.salesforceService.registerClientInSalesforce(data, clientData._id);
|
||||
|
||||
const response = await this.clientRepository.create(clientData);
|
||||
|
||||
if(response) {
|
||||
await this.salesforceService.registerClientInSalesforce(data, response._id);
|
||||
}
|
||||
// Send data to Salesforce first
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error("Client Service - registerClient error: " + error.message);
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@
|
|||
<i class="fas fa-users"></i>Virtual Assistants
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li class="nav-item">
|
||||
<a href="/client/invoices" class="nav-button">
|
||||
<i class="fas fa-file-invoice"></i>Invoices
|
||||
<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">
|
||||
<!-- <li class="nav-item">
|
||||
<a href="/client/timesheets" class="nav-button">
|
||||
<i class="fas fa-clock"></i>Timesheets
|
||||
</a>
|
||||
|
|
@ -73,13 +73,13 @@
|
|||
<span>Virtual Assistants</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li class="sidebar-item">
|
||||
<a href="/client/timesheets" class="sidebar-link">
|
||||
<i class="fas fa-clock sidebar-icon"></i>
|
||||
<span>Timesheets</span>
|
||||
<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">
|
||||
<!-- <li class="sidebar-item">
|
||||
<a href="/client/invoices" class="sidebar-link">
|
||||
<i class="fas fa-file-invoice sidebar-icon"></i>
|
||||
<span>Invoices</span>
|
||||
|
|
@ -148,8 +148,8 @@
|
|||
<th>Status</th>
|
||||
<th>Virtual Assistant</th>
|
||||
<th>Projects Assigned</th>
|
||||
<th>Working from</th>
|
||||
<th>Total Worked</th>
|
||||
<th>Active Projects</th>
|
||||
<th>Worked Time</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -222,7 +222,7 @@
|
|||
|
||||
// Format time function
|
||||
function formatTime(seconds) {
|
||||
if (!seconds) return '';
|
||||
if (!seconds) return 'Not working';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
|
@ -231,8 +231,6 @@
|
|||
return `${hours}h ${minutes}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
} else if (seconds > 0) {
|
||||
return `${seconds}s`;
|
||||
} else {
|
||||
return 'Just started';
|
||||
}
|
||||
|
|
@ -242,8 +240,8 @@
|
|||
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 totalWorkedTime = formatTime(va.totalWorkedSeconds);
|
||||
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>
|
||||
|
|
@ -251,7 +249,7 @@
|
|||
</a>` : '';
|
||||
|
||||
return `
|
||||
<tr data-vaid="${va.id}">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot ${statusClass}"></div>
|
||||
|
|
@ -264,15 +262,15 @@
|
|||
<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>
|
||||
<span class="worked-time">${totalWorkedTime}</span>
|
||||
</td>
|
||||
<td>
|
||||
${viewLink}
|
||||
</td>
|
||||
|
|
@ -309,35 +307,16 @@
|
|||
vaTableBody.innerHTML = allVAs.map(va => createVATableRow(va)).join('');
|
||||
vaTableBody.style.display = '';
|
||||
vaEmptyState.style.display = 'none';
|
||||
|
||||
// Add event listeners to table rows
|
||||
document.querySelectorAll('tr[data-vaid]').forEach(row => {
|
||||
row.addEventListener('click', async function(e) {
|
||||
// Don't trigger if click is on view link
|
||||
if (e.target.closest('.view-link')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vaId = this.getAttribute('data-vaid');
|
||||
const vaData = allVAs.find(va => va.id === vaId);
|
||||
|
||||
if (vaData) {
|
||||
// Show loading state
|
||||
const originalContent = this.innerHTML;
|
||||
this.innerHTML = '<td colspan="6" style="text-align: center;"><i class="fas fa-spinner fa-spin"></i> Loading...</td>';
|
||||
|
||||
// Fetch additional VA details from /test API
|
||||
const projectData = await fetchVADetails(vaId);
|
||||
|
||||
// Restore original content
|
||||
this.innerHTML = originalContent;
|
||||
|
||||
// Show popup with VA details
|
||||
showVAPopup(projectData);
|
||||
}
|
||||
|
||||
// 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';
|
||||
|
|
@ -377,127 +356,15 @@
|
|||
// Optional: Auto-refresh data every 30 seconds
|
||||
setInterval(loadDashboardData, 30000);
|
||||
|
||||
function createPopup(vaData) {
|
||||
// Extract project details from the API response
|
||||
// The API response is the project array, so use it directly
|
||||
const projectDetails = Array.isArray(vaData) ? vaData : (vaData.projects || []);
|
||||
|
||||
// Create project details HTML
|
||||
const projectsHTML = projectDetails.length > 0
|
||||
? projectDetails.map(project => `
|
||||
<div class="project-item">
|
||||
<div class="project-header">
|
||||
<span class="project-name">${project.projectName || 'Unknown Project'}</span>
|
||||
<span class="project-time">${project.totalTime || '00:00:00'}</span>
|
||||
</div>
|
||||
<div class="project-id">ID: ${project.projectId || 'N/A'}</div>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="no-projects">No project data available</div>';
|
||||
|
||||
return `
|
||||
<div class="popup-overlay" id="vaPopup">
|
||||
<div class="popup-content">
|
||||
<div class="popup-header">
|
||||
<h3>Project Work Details</h3>
|
||||
<button class="popup-close" id="closePopup">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<!-- Project Count -->
|
||||
<div class="project-summary">
|
||||
<div class="total-projects">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span>Total Projects: ${projectDetails.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Details List -->
|
||||
<div class="projects-list">
|
||||
${projectsHTML}
|
||||
</div>
|
||||
</div>
|
||||
<div class="popup-footer">
|
||||
<button class="btn btn-secondary" id="closePopupBtn">
|
||||
<i class="fas fa-times"></i> Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Show popup with VA details
|
||||
function showVAPopup(projectData) {
|
||||
console.log('Project Data:', projectData);
|
||||
|
||||
// Remove existing popup if any
|
||||
const existingPopup = document.getElementById('vaPopup');
|
||||
if (existingPopup) {
|
||||
existingPopup.remove();
|
||||
}
|
||||
|
||||
// Create and show new popup with only project data
|
||||
const popupHTML = createPopup(projectData);
|
||||
document.body.insertAdjacentHTML('beforeend', popupHTML);
|
||||
|
||||
// Add event listeners for closing popup
|
||||
document.getElementById('closePopup').addEventListener('click', closePopup);
|
||||
document.getElementById('closePopupBtn').addEventListener('click', closePopup);
|
||||
|
||||
// Close popup when clicking outside
|
||||
document.getElementById('vaPopup').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closePopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Close popup with Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closePopup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch VA details for popup - now returns only project data
|
||||
async function fetchVADetails(vaId) {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_BASE}/dashboard/va-report`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ vaId: vaId }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
|
||||
const result = await response.json();
|
||||
console.log('API Response:', result);
|
||||
|
||||
if (result.success) {
|
||||
// Return the projects array directly
|
||||
return result.projects || [];
|
||||
} else {
|
||||
console.error('Failed to fetch project details:', result.message);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching project details:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Close popup function
|
||||
function closePopup() {
|
||||
const popup = document.getElementById('vaPopup');
|
||||
if (popup) {
|
||||
popup.remove();
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,378 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocVA Client | Dashboard</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/public/css/dashboard.css">
|
||||
</head>
|
||||
<body class="dashboard-body">
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
<script>
|
||||
// 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>
|
||||
|
|
@ -29,12 +29,12 @@
|
|||
<i class="fas fa-users"></i>Virtual Assistants
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li class="nav-item">
|
||||
<a href="/client/invoices" class="nav-button">
|
||||
<i class="fas fa-file-invoice"></i>Invoices
|
||||
<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">
|
||||
<!-- <li class="nav-item">
|
||||
<a href="/client/timesheets" class="nav-button">
|
||||
<i class="fas fa-clock"></i>Timesheets
|
||||
</a>
|
||||
|
|
@ -73,13 +73,13 @@
|
|||
<span>Virtual Assistants</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li class="sidebar-item">
|
||||
<a href="/client/timesheets" class="sidebar-link">
|
||||
<i class="fas fa-clock sidebar-icon"></i>
|
||||
<span>Timesheets</span>
|
||||
<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">
|
||||
<!-- <li class="sidebar-item">
|
||||
<a href="/client/invoices" class="sidebar-link">
|
||||
<i class="fas fa-file-invoice sidebar-icon"></i>
|
||||
<span>Invoices</span>
|
||||
|
|
@ -148,6 +148,7 @@
|
|||
<th>Status</th>
|
||||
<th>Virtual Assistant</th>
|
||||
<th>Projects Assigned</th>
|
||||
<th>Active Projects</th>
|
||||
<th>Worked Time</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
|
|
@ -239,6 +240,7 @@
|
|||
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">
|
||||
|
|
@ -260,6 +262,9 @@
|
|||
<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>
|
||||
|
|
@ -350,6 +355,15 @@
|
|||
|
||||
// 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>
|
||||
|
|
|
|||
|
|
@ -276,11 +276,11 @@
|
|||
if (!res.ok) {
|
||||
throw new Error(data.message || "Login failed");
|
||||
}
|
||||
|
||||
if (data.accessToken) {
|
||||
|
||||
if (data.success) {
|
||||
// 🟢 Save token
|
||||
localStorage.setItem("knoptpq", data.accessToken);
|
||||
localStorage.setItem("refreshToken", data.refreshToken);
|
||||
// localStorage.setItem("refreshToken", data.refreshToken);
|
||||
|
||||
// 🟢 Redirect
|
||||
window.location.href = "/client/dashboard";
|
||||
|
|
|
|||
|
|
@ -127,6 +127,11 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
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,}$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
(function () {
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const logo = document.getElementById("logo");
|
||||
|
|
@ -154,8 +159,8 @@
|
|||
e.preventDefault();
|
||||
|
||||
const email = emailInput.value.trim();
|
||||
if (!email) {
|
||||
alert("Please enter your email address.");
|
||||
if (!email || !validateEmail(email)) {
|
||||
alert("Please enter valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -174,8 +179,13 @@
|
|||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
if(confirm('Please Check your mail inbox for password reset link')) {
|
||||
window.location.href = "/client/login"; // Redirect to reset password page
|
||||
if(response.success) {
|
||||
if(confirm('Please Check your mail inbox for password reset link')) {
|
||||
window.location.href = "/client/login"; // Redirect to reset password page
|
||||
// console.log(response)
|
||||
}
|
||||
} else {
|
||||
alert(data.message || "Failed to send reset link.");
|
||||
}
|
||||
} else {
|
||||
alert("❌ " + (data.message || "Failed to send reset link."));
|
||||
|
|
|
|||
|
|
@ -92,12 +92,18 @@
|
|||
<!-- Step 1: Create Account -->
|
||||
<div class="form-step active" id="step-1">
|
||||
<h2 class="form-title">Create Your Account</h2>
|
||||
<div class="step-error-container" id="step-1-error" style="display: none;">
|
||||
<div class="error-message" id="step-1-error-message"></div>
|
||||
</div>
|
||||
<form id="account-form">
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="company-name" class="input-field" placeholder=" " required>
|
||||
<input type="text" id="company-name" class="input-field" placeholder=" " oninput="validateCompanyName(this)" required>
|
||||
<label for="company-name" class="input-label">Company / Client 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="input-container">
|
||||
|
|
@ -107,7 +113,7 @@
|
|||
</div>
|
||||
<div class="form-actions">
|
||||
<div></div> <!-- Empty div for spacing -->
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(2)">Continue</button>
|
||||
<button type="button" class="btn btn-primary" onclick="handleNextStep(2)">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -115,6 +121,9 @@
|
|||
<!-- Step 2: Business Information -->
|
||||
<div class="form-step" id="step-2">
|
||||
<h2 class="form-title">Business Information</h2>
|
||||
<div class="step-error-container" id="step-2-error" style="display: none;">
|
||||
<div class="error-message" id="step-2-error-message"></div>
|
||||
</div>
|
||||
<form id="business-form">
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
|
|
@ -124,9 +133,12 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="city" class="input-field" placeholder=" " 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>
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
|
|
@ -141,13 +153,16 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="zip" class="input-field" placeholder=" " 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>
|
||||
</div>
|
||||
<div class="invalid-feedback" id="zip-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
|
||||
ZIP code should contain only numbers
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="prevStep(1)">Back</button>
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(3)">Continue</button>
|
||||
<button type="button" class="btn btn-primary" onclick="handleNextStep(3)">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -155,6 +170,9 @@
|
|||
<!-- Step 3: Billing Address -->
|
||||
<div class="form-step" id="step-3">
|
||||
<h2 class="form-title">Billing Address</h2>
|
||||
<div class="step-error-container" id="step-3-error" style="display: none;">
|
||||
<div class="error-message" id="step-3-error-message"></div>
|
||||
</div>
|
||||
<form id="billing-form">
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="use-business-address">
|
||||
|
|
@ -174,9 +192,12 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="billing-city" class="input-field" placeholder=" " 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>
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
|
|
@ -190,9 +211,12 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="billing-zip" class="input-field" placeholder=" " 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>
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="use-account-email">
|
||||
|
|
@ -206,7 +230,7 @@
|
|||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="prevStep(2)">Back</button>
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(4)">Continue</button>
|
||||
<button type="button" class="btn btn-primary" onclick="handleNextStep(4)">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -214,21 +238,30 @@
|
|||
<!-- Step 4: Authorized Signatory -->
|
||||
<div class="form-step" id="step-4">
|
||||
<h2 class="form-title">Authorized Signatory</h2>
|
||||
<div class="step-error-container" id="step-4-error" style="display: none;">
|
||||
<div class="error-message" id="step-4-error-message"></div>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
<form id="signatory-form">
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="position" class="input-field" placeholder=" " required>
|
||||
<input type="text" id="position" class="input-field" placeholder=" " oninput="validatePosition(this)" onkeypress="return preventInvalidPositionChars(event)" required>
|
||||
<label for="position" class="input-label">Position (Owner / CEO / Manager / etc.) *</label>
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="signatory-name" class="input-field" placeholder=" " 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>
|
||||
</div>
|
||||
<div class="invalid-feedback" id="signatory-name-error" style="display: none; color: #dc3545; font-size: 12px; margin-top: 5px;">
|
||||
Name should not contain numbers
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
|
|
@ -238,7 +271,7 @@
|
|||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="prevStep(3)">Back</button>
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(5)">Continue</button>
|
||||
<button type="button" class="btn btn-primary" onclick="handleNextStep(5)">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -246,6 +279,9 @@
|
|||
<!-- Step 5: Payment Details -->
|
||||
<div class="form-step" id="step-5">
|
||||
<h2 class="form-title">Payment Details</h2>
|
||||
<div class="step-error-container" id="step-5-error" style="display: none;">
|
||||
<div class="error-message" id="step-5-error-message"></div>
|
||||
</div>
|
||||
<form id="payment-form">
|
||||
<div class="payment-option" onclick="selectPaymentOption('card')">
|
||||
<h4>Credit or Debit Card</h4>
|
||||
|
|
@ -267,16 +303,22 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="security-code" class="input-field" placeholder=" " 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>
|
||||
</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=" " 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>
|
||||
</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')">
|
||||
|
|
@ -286,15 +328,21 @@
|
|||
<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=" " 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>
|
||||
</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=" " 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>
|
||||
</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">
|
||||
|
|
@ -314,9 +362,12 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="account-holder-name" class="input-field" placeholder=" " 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>
|
||||
</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="form-actions">
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Live Screen Feed</title>
|
||||
<title>Live Screen Feed</title>
|
||||
|
||||
<style>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -163,8 +163,7 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- Mobile Toggle Button -->
|
||||
<!-- Mobile Toggle Button -->
|
||||
<div class="mobile-toggle" id="toggleBtn">
|
||||
<span></span>
|
||||
<span></span>
|
||||
|
|
@ -189,13 +188,15 @@
|
|||
<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 %>"
|
||||
projectId: projectId,
|
||||
vaId: vaId
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -229,7 +230,9 @@
|
|||
};
|
||||
|
||||
/* Receive visible apps */
|
||||
socket.on("receiveVisibleApps", (apps) => {
|
||||
socket.on("receiveVisibleApps", ({apps, vaId: senderVaId}) => {
|
||||
if (vaId !== senderVaId) return;
|
||||
|
||||
appsList.innerHTML = "";
|
||||
|
||||
if (!apps?.length) {
|
||||
|
|
@ -252,7 +255,7 @@
|
|||
placeholder.style.display = "none";
|
||||
liveImage.style.display = "none";
|
||||
|
||||
socket.emit("selectApp", { appTitle: app.name, clientId, vaId });
|
||||
socket.emit("selectApp", { appTitle: app.name, clientId, vaId, projectId });
|
||||
|
||||
// Close sidebar on mobile
|
||||
sidebar.classList.remove("open");
|
||||
|
|
|
|||
|
|
@ -165,17 +165,20 @@
|
|||
<!-- Step 1: Account Setup -->
|
||||
<div class="form-step active" id="step-1">
|
||||
<h2 class="form-title">Account Setup</h2>
|
||||
<div class="step-error-container" id="step-1-error" style="display: none;">
|
||||
<div class="error-message" id="step-1-error-message"></div>
|
||||
</div>
|
||||
<form id="account-form">
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="email" id="email" class="input-field" placeholder=" " required>
|
||||
<input type="email" id="email" class="input-field" placeholder=" " required autocomplete="off">
|
||||
<label for="email" class="input-label">Email Address *</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="password" id="password" class="input-field" placeholder=" " required>
|
||||
<input type="password" id="password" class="input-field" placeholder=" " required autocomplete="off">
|
||||
<label for="password" class="input-label">Create Password *</label>
|
||||
</div>
|
||||
<div class="password-actions">
|
||||
|
|
@ -186,7 +189,7 @@
|
|||
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="password" id="confirmPassword" class="input-field" placeholder=" " required>
|
||||
<input type="password" id="confirmPassword" class="input-field" placeholder=" " required autocomplete="off">
|
||||
<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>
|
||||
|
|
@ -194,7 +197,7 @@
|
|||
|
||||
<div class="form-actions">
|
||||
<div></div>
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(2)">Continue</button>
|
||||
<button type="button" class="btn btn-primary" onclick="handleNextStep(2)">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -202,7 +205,9 @@
|
|||
<!-- Step 2: Personal Information -->
|
||||
<div class="form-step" id="step-2">
|
||||
<h2 class="form-title">Personal Information</h2>
|
||||
|
||||
<div class="step-error-container" id="step-2-error" style="display: none;">
|
||||
<div class="error-message" id="step-2-error-message"></div>
|
||||
</div>
|
||||
<div class="profile-incomplete" id="profile-incomplete" style="display: none;">
|
||||
Profile is not yet complete, first name is missing
|
||||
</div>
|
||||
|
|
@ -232,7 +237,7 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="date" id="dob" class="input-field" placeholder=" ">
|
||||
<input type="date" id="dob" class="input-field dob" placeholder=" " onchange="handleDOBChange()">
|
||||
<label for="dob" class="input-label">Date of Birth</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -274,7 +279,7 @@
|
|||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="prevStep(1)">Back</button>
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(3)">Continue</button>
|
||||
<button type="button" class="btn btn-primary" onclick="handleNextStep(3)">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -282,13 +287,19 @@
|
|||
<!-- Step 3: Contact Information -->
|
||||
<div class="form-step" id="step-3">
|
||||
<h2 class="form-title">Contact Information</h2>
|
||||
<div class="step-error-container" id="step-3-error" style="display: none;">
|
||||
<div class="error-message" id="step-3-error-message"></div>
|
||||
</div>
|
||||
<form id="contact-form">
|
||||
<div class="form-group">
|
||||
<label for="phone" class="input-label">Phone Number *</label>
|
||||
<div class="input-container">
|
||||
<input type="tel" id="phone" class="input-field" placeholder=" " 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>
|
||||
</div>
|
||||
<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)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -300,7 +311,7 @@
|
|||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="prevStep(2)">Back</button>
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(4)">Continue</button>
|
||||
<button type="button" class="btn btn-primary" onclick="handleNextStep(4)">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -308,6 +319,9 @@
|
|||
<!-- Step 4: Payment Details -->
|
||||
<div class="form-step" id="step-4">
|
||||
<h2 class="form-title">Payment Details</h2>
|
||||
<div class="step-error-container" id="step-4-error" style="display: none;">
|
||||
<div class="error-message" id="step-4-error-message"></div>
|
||||
</div>
|
||||
<form id="payment-form">
|
||||
<div class="form-group">
|
||||
<div class="payment-option" onclick="selectPaymentMethod('payoneer')">
|
||||
|
|
@ -317,9 +331,12 @@
|
|||
<div id="payoneer-details" class="payment-details">
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="payoneerAccount" class="input-field" placeholder=" " required>
|
||||
<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>
|
||||
|
||||
|
|
@ -330,9 +347,12 @@
|
|||
<div id="veem-details" class="payment-details">
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input type="text" id="veemAccount" class="input-field" placeholder=" " required>
|
||||
<input type="text" id="veemAccount" class="input-field" placeholder=" " minlength="8" maxlength="25" oninput="validateVeemAccount(this)" required>
|
||||
<label for="veemAccount" class="input-label">Veem Account Number *</label>
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue