Compare commits
5 Commits
8254fab09a
...
36bdc24b54
| Author | SHA1 | Date |
|---|---|---|
|
|
36bdc24b54 | |
|
|
acb1061de5 | |
|
|
e9ff5ec612 | |
|
|
4a2734cf2c | |
|
|
357a6e1be7 |
|
|
@ -644,3 +644,305 @@ body {
|
|||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Popup Styles */
|
||||
.popup-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: popupFadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes popupFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.popup-header h3 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.popup-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.va-details-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
color: #6b7280;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
color: #059669;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
/* Make table rows clickable */
|
||||
tr[data-vaid] {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
tr[data-vaid]:hover {
|
||||
background-color: #f8fafc !important;
|
||||
}
|
||||
|
||||
/* Ensure view link doesn't trigger row click */
|
||||
.view-link {
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Project Details Styles in Popup */
|
||||
.projects-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.projects-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.projects-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.project-time {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-id {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.no-projects {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #d1d5db;
|
||||
}
|
||||
|
||||
/* Adjust basic info layout */
|
||||
.va-basic-info {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
color: #6b7280;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
color: #059669;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Popup responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.project-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.project-time {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 504 B |
Binary file not shown.
|
After Width: | Height: | Size: 782 B |
Binary file not shown.
|
After Width: | Height: | Size: 255 B |
|
|
@ -594,7 +594,7 @@
|
|||
|
||||
nextStep(5);
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
window.location.href = '/download-tracker';
|
||||
}, 1000);
|
||||
} else {
|
||||
// Handle server errors
|
||||
|
|
|
|||
|
|
@ -105,12 +105,92 @@ async function client_dashboard(req, res) {
|
|||
return acc;
|
||||
}, {});
|
||||
|
||||
// Calculate total worked time for each VA
|
||||
const totalWorkedTimeByVa = {};
|
||||
|
||||
// Get all logs for client's VAs to calculate total worked time
|
||||
const allLogs = await LoggedModel.find({
|
||||
vaId: { $in: clientVaIds },
|
||||
projectId: { $in: clientProjectIds }
|
||||
})
|
||||
.sort({ time: 1 })
|
||||
.lean();
|
||||
|
||||
// Group logs by VA
|
||||
const logsByVa = {};
|
||||
allLogs.forEach(log => {
|
||||
const vaId = log.vaId.toString();
|
||||
if (!logsByVa[vaId]) {
|
||||
logsByVa[vaId] = [];
|
||||
}
|
||||
logsByVa[vaId].push(log);
|
||||
});
|
||||
|
||||
// Calculate total worked time for each VA using the same logic as va_log_work_report_callback
|
||||
for (const [vaId, logs] of Object.entries(logsByVa)) {
|
||||
let totalWorkSeconds = 0;
|
||||
|
||||
// Group logs by date (YYYY-MM-DD format)
|
||||
const logsByDate = {};
|
||||
logs.forEach(log => {
|
||||
const date = new Date(log.time).toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
if (!logsByDate[date]) {
|
||||
logsByDate[date] = [];
|
||||
}
|
||||
logsByDate[date].push(log);
|
||||
});
|
||||
|
||||
// Process each day separately
|
||||
for (const [date, dayLogs] of Object.entries(logsByDate)) {
|
||||
// Check if this day has any adjustment logs
|
||||
const hasAdjustmentLogs = dayLogs.some(log => log.isAdjustment === true);
|
||||
|
||||
let dayWorkSeconds = 0;
|
||||
let lastLogin = null;
|
||||
let lastProjectId = null;
|
||||
|
||||
// If day has adjustment logs, only process adjustment logs
|
||||
// If no adjustment logs, process all logs for that day
|
||||
const logsToProcess = hasAdjustmentLogs
|
||||
? dayLogs.filter(log => log.isAdjustment === true)
|
||||
: dayLogs;
|
||||
|
||||
// Sort logs by time for proper pairing
|
||||
logsToProcess.sort((a, b) => new Date(a.time) - new Date(b.time));
|
||||
|
||||
// Calculate work duration for this day
|
||||
for (const log of logsToProcess) {
|
||||
const time = new Date(log.time);
|
||||
|
||||
if (log.loggedType === "LOGIN") {
|
||||
lastLogin = time;
|
||||
lastProjectId = log.projectId?.toString() || null;
|
||||
} else if (log.loggedType === "LOGOUT" && lastLogin) {
|
||||
const diffSec = (time - lastLogin) / 1000;
|
||||
dayWorkSeconds += diffSec;
|
||||
|
||||
// reset after each pair
|
||||
lastLogin = null;
|
||||
lastProjectId = null;
|
||||
}
|
||||
}
|
||||
|
||||
totalWorkSeconds += dayWorkSeconds;
|
||||
}
|
||||
|
||||
totalWorkedTimeByVa[vaId] = {
|
||||
totalSeconds: totalWorkSeconds,
|
||||
formatted: formatTime(totalWorkSeconds)
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate session duration for currently active VAs
|
||||
const currentTime = new Date();
|
||||
|
||||
// Format all assigned VAs
|
||||
const allVAsFormatted = allAssignedVAs.map((v) => {
|
||||
const activity = activityByVa[String(v._id)] || {};
|
||||
const workedTime = totalWorkedTimeByVa[String(v._id)] || { totalSeconds: 0, formatted: '00:00:00' };
|
||||
const lastLog = activity.lastLog;
|
||||
|
||||
// Calculate current session duration if still logged in
|
||||
|
|
@ -140,10 +220,12 @@ async function client_dashboard(req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
// Combine first and last name
|
||||
const fullName = `${v.userDetails?.firstName || ''} ${v.userDetails?.lastName || ''}`.trim() || 'Unknown';
|
||||
|
||||
return {
|
||||
id: v._id,
|
||||
firstName: v.userDetails?.firstName || '',
|
||||
lastName: v.userDetails?.lastName || '',
|
||||
name: fullName,
|
||||
email: v.userDetails?.email,
|
||||
phone: v.userDetails?.phone,
|
||||
status: v.status || 'active',
|
||||
|
|
@ -152,8 +234,10 @@ async function client_dashboard(req, res) {
|
|||
lastActivity: activity.lastActivity,
|
||||
currentSessionDuration: currentSessionDuration,
|
||||
currentSessionFormatted: currentSessionFormatted,
|
||||
totalWorkedTime: workedTime.formatted,
|
||||
totalWorkedSeconds: workedTime.totalSeconds,
|
||||
totalLogins: activity.totalLogins || 0,
|
||||
lastProject: currentProjectDetails, // Now with project details
|
||||
lastProject: currentProjectDetails,
|
||||
lastProjectId: lastLog?.projectId || null,
|
||||
projectsWorked: activity.projectsWorked || [],
|
||||
projectsAssigned: v.projectIds?.length || 0,
|
||||
|
|
@ -164,6 +248,7 @@ async function client_dashboard(req, res) {
|
|||
// Format active VAs (currently working)
|
||||
const activeVAsFormatted = activeVAs.map((v) => {
|
||||
const activity = activityByVa[String(v._id)] || {};
|
||||
const workedTime = totalWorkedTimeByVa[String(v._id)] || { totalSeconds: 0, formatted: '00:00:00' };
|
||||
const lastLog = activity.lastLog;
|
||||
|
||||
let currentSessionDuration = null;
|
||||
|
|
@ -184,16 +269,20 @@ async function client_dashboard(req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
// Combine first and last name
|
||||
const fullName = `${v.userDetails?.firstName || ''} ${v.userDetails?.lastName || ''}`.trim() || 'Unknown';
|
||||
|
||||
return {
|
||||
id: v._id,
|
||||
firstName: v.userDetails?.firstName || '',
|
||||
lastName: v.userDetails?.lastName || '',
|
||||
name: fullName,
|
||||
email: v.userDetails?.email,
|
||||
phone: v.userDetails?.phone,
|
||||
status: v.status || 'active',
|
||||
lastLoginTime: lastLog?.time || null,
|
||||
currentSessionDuration: currentSessionDuration,
|
||||
currentSessionFormatted: currentSessionFormatted,
|
||||
totalWorkedTime: workedTime.formatted,
|
||||
totalWorkedSeconds: workedTime.totalSeconds,
|
||||
totalLogins: activity.totalLogins || 0,
|
||||
currentProject: currentProjectDetails,
|
||||
currentProjectId: lastLog?.projectId || null,
|
||||
|
|
@ -242,6 +331,9 @@ async function client_dashboard(req, res) {
|
|||
const currentlyActiveVAs = allVAsFormatted.filter(va => va.isCurrentlyActive);
|
||||
const inactiveVAs = allVAsFormatted.filter(va => !va.isCurrentlyActive);
|
||||
|
||||
// Calculate total worked time across all VAs
|
||||
const totalWorkedTimeAllVAs = allVAsFormatted.reduce((total, va) => total + (va.totalWorkedSeconds || 0), 0);
|
||||
|
||||
// Calculate projects with active workers
|
||||
const projectsWithActiveWorkers = projectsFormatted.filter(p => p.vaCurrentlyWorking > 0).length;
|
||||
|
||||
|
|
@ -267,6 +359,7 @@ async function client_dashboard(req, res) {
|
|||
projectsWithActiveWorkers: projectsWithActiveWorkers,
|
||||
totalHoursLogged: totalHoursLogged,
|
||||
totalDedicatedHours: totalDedicatedHours,
|
||||
totalWorkedTime: formatTime(totalWorkedTimeAllVAs),
|
||||
utilizationRate: totalDedicatedHours > 0 ?
|
||||
((totalHoursLogged / totalDedicatedHours) * 100).toFixed(2) + '%' : '0%',
|
||||
lastUpdated: currentTime.toISOString()
|
||||
|
|
@ -293,19 +386,12 @@ async function client_dashboard(req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
// Helper function to format time
|
||||
// Helper function to format time (same as in va_log_work_report_callback)
|
||||
function formatTime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${remainingSeconds}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
} else {
|
||||
return `${remainingSeconds}s`;
|
||||
}
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export { client_dashboard };
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { VAModel, ProjectModel, LoggedModel, ClientModel } from "../../../models/vaModel.js";
|
||||
|
||||
// Helper function to parse date string in DD-MM-YYYY format
|
||||
function parseDate(dateString) {
|
||||
if (!dateString) return null;
|
||||
|
||||
const parts = dateString.split('-');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const day = parseInt(parts[0], 10);
|
||||
const month = parseInt(parts[1], 10) - 1; // Months are 0-indexed in JavaScript
|
||||
const year = parseInt(parts[2], 10);
|
||||
|
||||
// Validate date components
|
||||
if (isNaN(day) || isNaN(month) || isNaN(year)) return null;
|
||||
if (day < 1 || day > 31 || month < 0 || month > 11 || year < 1900) return null;
|
||||
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
// Getting logged hours in a specific project of VA
|
||||
async function client_dashboard_va_log(req, res) {
|
||||
console.log(req)
|
||||
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" });
|
||||
}
|
||||
|
||||
const clientID = clientUser._id;
|
||||
const clientProjectIds = clientUser.projectIds || [];
|
||||
|
||||
const payload = req.body || {};
|
||||
const { vaId } = payload;
|
||||
|
||||
// const va = await VAModel.findOne({
|
||||
// _id: vaId,
|
||||
// clientIds: clientID
|
||||
// });
|
||||
|
||||
// if (!va) {
|
||||
// return res.status(403).json({
|
||||
// success: false,
|
||||
// message: "VA not found or not assigned to this client"
|
||||
// });
|
||||
// }
|
||||
|
||||
let logs = await LoggedModel.find({
|
||||
vaId,
|
||||
projectId: { $in: clientProjectIds },
|
||||
})
|
||||
.sort({ time: 1 })
|
||||
.populate("projectId", "name") // attach project name
|
||||
.lean();
|
||||
|
||||
if (!logs.length) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "No logs found in given range",
|
||||
data: [],
|
||||
totalWorkTime: "00:00:00",
|
||||
});
|
||||
}
|
||||
|
||||
let totalWorkSeconds = 0;
|
||||
const projectWork = {}; // key: projectId -> seconds
|
||||
|
||||
// Group logs by date (YYYY-MM-DD format)
|
||||
const logsByDate = {};
|
||||
logs.forEach(log => {
|
||||
const date = new Date(log.time).toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
if (!logsByDate[date]) {
|
||||
logsByDate[date] = [];
|
||||
}
|
||||
logsByDate[date].push(log);
|
||||
});
|
||||
|
||||
// Process each day separately
|
||||
for (const [date, dayLogs] of Object.entries(logsByDate)) {
|
||||
// Check if this day has any adjustment logs
|
||||
const hasAdjustmentLogs = dayLogs.some(log => log.isAdjustment === true);
|
||||
|
||||
let dayWorkSeconds = 0;
|
||||
let lastLogin = null;
|
||||
let lastProjectId = null;
|
||||
|
||||
// If day has adjustment logs, only process adjustment logs
|
||||
// If no adjustment logs, process all logs for that day
|
||||
const logsToProcess = hasAdjustmentLogs
|
||||
? dayLogs.filter(log => log.isAdjustment === true)
|
||||
: dayLogs;
|
||||
|
||||
// Sort logs by time for proper pairing (already sorted by query, but ensure)
|
||||
logsToProcess.sort((a, b) => new Date(a.time) - new Date(b.time));
|
||||
|
||||
// Calculate work duration for this day
|
||||
for (const log of logsToProcess) {
|
||||
const time = new Date(log.time);
|
||||
|
||||
if (log.loggedType === "LOGIN") {
|
||||
lastLogin = time;
|
||||
lastProjectId = log.projectId?._id?.toString() || null;
|
||||
} else if (log.loggedType === "LOGOUT" && lastLogin) {
|
||||
const diffSec = (time - lastLogin) / 1000;
|
||||
dayWorkSeconds += diffSec;
|
||||
|
||||
if (lastProjectId) {
|
||||
projectWork[lastProjectId] = (projectWork[lastProjectId] || 0) + diffSec;
|
||||
}
|
||||
|
||||
// reset after each pair
|
||||
lastLogin = null;
|
||||
lastProjectId = null;
|
||||
}
|
||||
}
|
||||
|
||||
totalWorkSeconds += dayWorkSeconds;
|
||||
}
|
||||
|
||||
// 🕓 Convert total seconds to hh:mm:ss
|
||||
const formatTime = (seconds) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// 🧠 Build project-wise array
|
||||
const projectDetails = await Promise.all(
|
||||
Object.entries(projectWork).map(async ([projectId, sec]) => {
|
||||
const project = await ProjectModel.findById(projectId).lean();
|
||||
return {
|
||||
projectId,
|
||||
projectName: project?.name || "Unknown Project",
|
||||
totalTime: formatTime(sec),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
totalWorkTime: formatTime(totalWorkSeconds),
|
||||
projects: projectDetails,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return res.status(500).send("Internal Server Error");
|
||||
}
|
||||
}
|
||||
|
||||
export { client_dashboard_va_log };
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { ProjectModel, VAModel, ClientModel } from "../../models/vaModel.js";
|
||||
import { Types } from "mongoose";
|
||||
import { updateEntityRelationships } from "../../helper/updateEntityRelationships.js";
|
||||
// Register a new VA
|
||||
async function project_add_sf(req, res) {
|
||||
try {
|
||||
const payload = req.body|| {};
|
||||
const { name, hourlyRate, clientId, vaAssigned, dedicatedHours } = payload;
|
||||
|
||||
// Validate required fields
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Project name is required"
|
||||
});
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Client ID is required"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate and convert clientId to ObjectId
|
||||
let clientObjectId;
|
||||
try {
|
||||
clientObjectId = new Types.ObjectId(clientId);
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid Client ID format"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate and convert vaAssigned to array of ObjectIds
|
||||
let vaAssignedArray = [];
|
||||
if (vaAssigned) {
|
||||
if (Array.isArray(vaAssigned)) {
|
||||
try {
|
||||
vaAssignedArray = vaAssigned.map(vaId => new Types.ObjectId(vaId));
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid VA ID format in vaAssigned array"
|
||||
});
|
||||
}
|
||||
} else if (typeof vaAssigned === 'string') {
|
||||
// Handle single VA ID as string
|
||||
try {
|
||||
vaAssignedArray = [new Types.ObjectId(vaAssigned)];
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid VA ID format"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sanitize/construct allowed fields
|
||||
const toCreate = {
|
||||
name: name.trim(),
|
||||
hourlyRate: hourlyRate || '0',
|
||||
clientId: clientObjectId,
|
||||
vaAssigned: vaAssignedArray,
|
||||
dedicatedHours: dedicatedHours ? parseFloat(dedicatedHours) : 0,
|
||||
};
|
||||
|
||||
const created = await ProjectModel.create(toCreate);
|
||||
|
||||
const relationshipUpdate = await updateEntityRelationships({
|
||||
project: {
|
||||
projectId: created._id,
|
||||
clientId: clientObjectId,
|
||||
vaId: vaAssignedArray
|
||||
}
|
||||
});
|
||||
|
||||
if (!relationshipUpdate.success) {
|
||||
console.warn('Project created but relationship update failed:', relationshipUpdate.message);
|
||||
}
|
||||
|
||||
// Send data to Salesforce API after successful registration
|
||||
try {
|
||||
// const salesforceUrl = `${process.env.SF_DOCTRACKER_API_URL}services/apexrest/docva/api/`;
|
||||
|
||||
// const response = await fetch(salesforceUrl, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify(created)
|
||||
// });
|
||||
|
||||
// if (!response.ok) {
|
||||
// const errorText = await response.text();
|
||||
// throw new Error(`Salesforce API responded with status ${response.status}: ${errorText}`);
|
||||
// }
|
||||
|
||||
// const result = await response.json();
|
||||
// console.log("✅ Successfully synced VA data to Salesforce:", result);
|
||||
|
||||
// res.status(201).json({ success: true, data: created, SyncSalesforce: result });
|
||||
res.status(201).json({ success: true, data: created, relationships: relationshipUpdate });
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to sync with Salesforce:", error.message);
|
||||
|
||||
// Create a custom error to distinguish Salesforce errors
|
||||
const salesforceError = new Error(`Salesforce sync failed: ${error.message}`);
|
||||
salesforceError.isSalesforceError = true;
|
||||
throw salesforceError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("project_add_sf error:", error);
|
||||
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
||||
}
|
||||
}
|
||||
|
||||
export { project_add_sf };
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import { ProjectModel } from "../../models/vaModel.js";
|
||||
import { Types } from "mongoose";
|
||||
import { updateEntityRelationships, removeEntityRelationships } from "../../helper/updateEntityRelationships.js";
|
||||
|
||||
/**
|
||||
* Update project details - PATCH endpoint
|
||||
* Supports updating: status, VA assignments, hourly rate, dedicated hours, etc.
|
||||
*/
|
||||
async function update_project_sf(req, res) {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const payload = req.body || {};
|
||||
const {
|
||||
name,
|
||||
hourlyRate,
|
||||
vaAssigned,
|
||||
dedicatedHours,
|
||||
status,
|
||||
totalLoggedHours
|
||||
} = payload;
|
||||
|
||||
// Validate projectId
|
||||
if (!projectId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Project ID is required"
|
||||
});
|
||||
}
|
||||
|
||||
let projectObjectId;
|
||||
try {
|
||||
projectObjectId = new Types.ObjectId(projectId);
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid Project ID format"
|
||||
});
|
||||
}
|
||||
|
||||
// Check if project exists
|
||||
const existingProject = await ProjectModel.findById(projectObjectId);
|
||||
if (!existingProject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Project not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {};
|
||||
let relationshipUpdate = null;
|
||||
let vaRelationshipUpdate = null;
|
||||
|
||||
// Update name if provided
|
||||
if (name !== undefined) {
|
||||
if (!name.trim()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Project name cannot be empty"
|
||||
});
|
||||
}
|
||||
updateData.name = name.trim();
|
||||
}
|
||||
|
||||
// Update hourly rate if provided
|
||||
if (hourlyRate !== undefined) {
|
||||
updateData.hourlyRate = hourlyRate;
|
||||
}
|
||||
|
||||
// Update dedicated hours if provided
|
||||
if (dedicatedHours !== undefined) {
|
||||
updateData.dedicatedHours = parseFloat(dedicatedHours) || 0;
|
||||
}
|
||||
|
||||
// Update total logged hours if provided
|
||||
if (totalLoggedHours !== undefined) {
|
||||
updateData.totalLoggedHours = parseFloat(totalLoggedHours) || 0;
|
||||
}
|
||||
|
||||
// Update status if provided
|
||||
if (status !== undefined) {
|
||||
if (!["ACTIVE", "INACTIVE"].includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Status must be either ACTIVE or INACTIVE"
|
||||
});
|
||||
}
|
||||
updateData.status = status;
|
||||
}
|
||||
|
||||
// Handle VA assignments - this requires relationship updates
|
||||
if (vaAssigned !== undefined) {
|
||||
let newVaAssignedArray = [];
|
||||
|
||||
// Parse and validate VA IDs
|
||||
if (vaAssigned && vaAssigned.length > 0) {
|
||||
if (Array.isArray(vaAssigned)) {
|
||||
try {
|
||||
newVaAssignedArray = vaAssigned.map(vaId => new Types.ObjectId(vaId));
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid VA ID format in vaAssigned array"
|
||||
});
|
||||
}
|
||||
} else if (typeof vaAssigned === 'string') {
|
||||
try {
|
||||
newVaAssignedArray = [new Types.ObjectId(vaAssigned)];
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid VA ID format"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateData.vaAssigned = newVaAssignedArray;
|
||||
|
||||
// Calculate VAs to add and remove for relationship updates
|
||||
const currentVaIds = existingProject.vaAssigned.map(va => va.toString());
|
||||
const newVaIds = newVaAssignedArray.map(va => va.toString());
|
||||
|
||||
const vaToAdd = newVaAssignedArray.filter(va => !currentVaIds.includes(va.toString()));
|
||||
const vaToRemove = existingProject.vaAssigned.filter(va => !newVaIds.includes(va.toString()));
|
||||
|
||||
// Update relationships for added VAs
|
||||
if (vaToAdd.length > 0) {
|
||||
relationshipUpdate = await updateEntityRelationships({
|
||||
project: {
|
||||
projectId: projectObjectId,
|
||||
clientId: existingProject.clientId,
|
||||
vaId: vaToAdd
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove relationships for removed VAs
|
||||
if (vaToRemove.length > 0) {
|
||||
vaRelationshipUpdate = await removeEntityRelationships({
|
||||
project: {
|
||||
projectId: projectObjectId,
|
||||
clientId: existingProject.clientId,
|
||||
vaId: vaToRemove
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no fields to update
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "No valid fields to update"
|
||||
});
|
||||
}
|
||||
|
||||
// Update the project
|
||||
const updatedProject = await ProjectModel.findByIdAndUpdate(
|
||||
projectObjectId,
|
||||
updateData,
|
||||
{ new: true, runValidators: true }
|
||||
).populate('clientId', 'userDetails.name userDetails.email')
|
||||
.populate('vaAssigned', 'userDetails.firstName userDetails.lastName userDetails.email');
|
||||
|
||||
// If VA assignments were changed but relationship update failed, log warning
|
||||
if (vaAssigned !== undefined) {
|
||||
if (relationshipUpdate && !relationshipUpdate.success) {
|
||||
console.warn('Project updated but VA relationship addition failed:', relationshipUpdate.message);
|
||||
}
|
||||
if (vaRelationshipUpdate && !vaRelationshipUpdate.success) {
|
||||
console.warn('Project updated but VA relationship removal failed:', vaRelationshipUpdate.message);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Project updated successfully",
|
||||
data: updatedProject,
|
||||
relationships: {
|
||||
added: relationshipUpdate,
|
||||
removed: vaRelationshipUpdate
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("update_project_sf error:", error);
|
||||
|
||||
// MongoDB duplicate key error
|
||||
if (error.code === 11000) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Project with this name already exists"
|
||||
});
|
||||
}
|
||||
|
||||
// MongoDB validation error
|
||||
if (error.name === 'ValidationError') {
|
||||
const errors = Object.values(error.errors).map(err => err.message);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Validation error",
|
||||
errors: errors
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal Server Error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
update_project_sf
|
||||
};
|
||||
|
|
@ -85,35 +85,56 @@ async function va_log_work_report_callback(req, res) {
|
|||
let totalWorkSeconds = 0;
|
||||
const projectWork = {}; // key: projectId -> seconds
|
||||
|
||||
let lastLogin = null;
|
||||
let lastProjectId = null;
|
||||
|
||||
const hasAdjustmentLogs = logs.some(log => log.isAdjustment === true);
|
||||
|
||||
if(hasAdjustmentLogs) {
|
||||
logs = logs.filter(log => log.isAdjustment === true);
|
||||
}
|
||||
|
||||
|
||||
// ⏱️ Calculate work duration
|
||||
for (const log of logs) {
|
||||
const time = new Date(log.time);
|
||||
|
||||
if (log.loggedType === "LOGIN") {
|
||||
lastLogin = time;
|
||||
lastProjectId = log.projectId?._id?.toString() || null;
|
||||
} else if (log.loggedType === "LOGOUT" && lastLogin) {
|
||||
const diffSec = (time - lastLogin) / 1000;
|
||||
totalWorkSeconds += diffSec;
|
||||
|
||||
if (lastProjectId) {
|
||||
projectWork[lastProjectId] = (projectWork[lastProjectId] || 0) + diffSec;
|
||||
}
|
||||
|
||||
// reset after each pair
|
||||
lastLogin = null;
|
||||
lastProjectId = null;
|
||||
// Group logs by date (YYYY-MM-DD format)
|
||||
const logsByDate = {};
|
||||
logs.forEach(log => {
|
||||
const date = new Date(log.time).toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
if (!logsByDate[date]) {
|
||||
logsByDate[date] = [];
|
||||
}
|
||||
logsByDate[date].push(log);
|
||||
});
|
||||
|
||||
// Process each day separately
|
||||
for (const [date, dayLogs] of Object.entries(logsByDate)) {
|
||||
// Check if this day has any adjustment logs
|
||||
const hasAdjustmentLogs = dayLogs.some(log => log.isAdjustment === true);
|
||||
|
||||
let dayWorkSeconds = 0;
|
||||
let lastLogin = null;
|
||||
let lastProjectId = null;
|
||||
|
||||
// If day has adjustment logs, only process adjustment logs
|
||||
// If no adjustment logs, process all logs for that day
|
||||
const logsToProcess = hasAdjustmentLogs
|
||||
? dayLogs.filter(log => log.isAdjustment === true)
|
||||
: dayLogs;
|
||||
|
||||
// Sort logs by time for proper pairing (already sorted by query, but ensure)
|
||||
logsToProcess.sort((a, b) => new Date(a.time) - new Date(b.time));
|
||||
|
||||
// Calculate work duration for this day
|
||||
for (const log of logsToProcess) {
|
||||
const time = new Date(log.time);
|
||||
|
||||
if (log.loggedType === "LOGIN") {
|
||||
lastLogin = time;
|
||||
lastProjectId = log.projectId?._id?.toString() || null;
|
||||
} else if (log.loggedType === "LOGOUT" && lastLogin) {
|
||||
const diffSec = (time - lastLogin) / 1000;
|
||||
dayWorkSeconds += diffSec;
|
||||
|
||||
if (lastProjectId) {
|
||||
projectWork[lastProjectId] = (projectWork[lastProjectId] || 0) + diffSec;
|
||||
}
|
||||
|
||||
// reset after each pair
|
||||
lastLogin = null;
|
||||
lastProjectId = null;
|
||||
}
|
||||
}
|
||||
|
||||
totalWorkSeconds += dayWorkSeconds;
|
||||
}
|
||||
|
||||
// 🕓 Convert total seconds to hh:mm:ss
|
||||
|
|
|
|||
|
|
@ -60,13 +60,17 @@ async function va_timesheet_adjustment_function(req, res) {
|
|||
const combineDateTime = (dateObj, timeStr) => {
|
||||
const [hours, minutes, seconds] = timeStr.split(':').map(Number);
|
||||
|
||||
// Extract date components from the Date object
|
||||
const year = dateObj.getFullYear();
|
||||
const month = dateObj.getMonth();
|
||||
const day = dateObj.getDate();
|
||||
// // Extract date components from the Date object
|
||||
// const year = dateObj.getFullYear();
|
||||
// const month = dateObj.getMonth();
|
||||
// const day = dateObj.getDate();
|
||||
|
||||
// Create UTC date directly
|
||||
return new Date(Date.UTC(year, month, day, hours, minutes, seconds));
|
||||
// // Create UTC date directly
|
||||
// return new Date(year, month, day, hours, minutes, seconds);
|
||||
const combined = new Date(dateObj);
|
||||
combined.setHours(hours, minutes, seconds, 0);
|
||||
|
||||
return combined;
|
||||
};
|
||||
|
||||
const loginDateTime = combineDateTime(adjustmentDate, loginTime);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
import { ClientModel, VAModel, ProjectModel } from "../models/vaModel.js";
|
||||
|
||||
/**
|
||||
* Generic function to update relationships between Client, VA, and Project
|
||||
* @param {Object} data - The update data object
|
||||
* @returns {Object} - Result of the update operations
|
||||
*/
|
||||
async function updateEntityRelationships(data) {
|
||||
try {
|
||||
let results = {};
|
||||
|
||||
// Handle Project updates
|
||||
if (data.project) {
|
||||
const { projectId, vaId, clientId } = data.project;
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error('Project ID is required for project updates');
|
||||
}
|
||||
|
||||
// Update Client with projectId
|
||||
if (clientId) {
|
||||
const clientUpdate = await ClientModel.findByIdAndUpdate(
|
||||
clientId,
|
||||
{
|
||||
$addToSet: {
|
||||
projectIds: projectId
|
||||
}
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
results.clientUpdate = clientUpdate ? 'Client updated successfully' : 'Client not found';
|
||||
}
|
||||
|
||||
// Update VAs with projectId
|
||||
if (vaId && Array.isArray(vaId) && vaId.length > 0) {
|
||||
const vaUpdateResult = await VAModel.updateMany(
|
||||
{ _id: { $in: vaId } },
|
||||
{
|
||||
$addToSet: {
|
||||
projectIds: projectId
|
||||
}
|
||||
}
|
||||
);
|
||||
results.vaUpdate = `Updated ${vaUpdateResult.modifiedCount} VAs`;
|
||||
}
|
||||
|
||||
// Update Project with clientId and vaId
|
||||
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(
|
||||
projectId,
|
||||
projectUpdateData,
|
||||
{ new: true }
|
||||
);
|
||||
results.projectUpdate = projectUpdate ? 'Project updated successfully' : 'Project not found';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle VA updates
|
||||
if (data.va) {
|
||||
const { vaId, projectId, clientId } = data.va;
|
||||
|
||||
if (!vaId) {
|
||||
throw new Error('VA ID is required for VA updates');
|
||||
}
|
||||
|
||||
// Update Projects with vaId
|
||||
if (projectId && Array.isArray(projectId) && projectId.length > 0) {
|
||||
const projectUpdateResult = await ProjectModel.updateMany(
|
||||
{ _id: { $in: projectId } },
|
||||
{
|
||||
$addToSet: {
|
||||
vaAssigned: vaId
|
||||
}
|
||||
}
|
||||
);
|
||||
results.projectUpdate = `Updated ${projectUpdateResult.modifiedCount} projects`;
|
||||
}
|
||||
|
||||
// Update Clients with vaId
|
||||
if (clientId && Array.isArray(clientId) && clientId.length > 0) {
|
||||
const clientUpdateResult = await ClientModel.updateMany(
|
||||
{ _id: { $in: clientId } },
|
||||
{
|
||||
$addToSet: {
|
||||
vaIds: vaId
|
||||
}
|
||||
}
|
||||
);
|
||||
results.clientUpdate = `Updated ${clientUpdateResult.modifiedCount} clients`;
|
||||
}
|
||||
|
||||
// Update VA with clientId and projectId
|
||||
const vaUpdateData = {};
|
||||
if (clientId && Array.isArray(clientId)) {
|
||||
vaUpdateData.clientIds = clientId;
|
||||
}
|
||||
if (projectId && Array.isArray(projectId)) {
|
||||
vaUpdateData.projectIds = projectId;
|
||||
}
|
||||
|
||||
if (Object.keys(vaUpdateData).length > 0) {
|
||||
const vaUpdate = await VAModel.findByIdAndUpdate(
|
||||
vaId,
|
||||
{ $addToSet: vaUpdateData },
|
||||
{ new: true }
|
||||
);
|
||||
results.vaUpdate = vaUpdate ? 'VA updated successfully' : 'VA not found';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Client updates
|
||||
if (data.client) {
|
||||
const { clientId, projectId, vaId } = data.client;
|
||||
|
||||
if (!clientId) {
|
||||
throw new Error('Client ID is required for client updates');
|
||||
}
|
||||
|
||||
// Update Projects with clientId
|
||||
if (projectId && Array.isArray(projectId) && projectId.length > 0) {
|
||||
const projectUpdateResult = await ProjectModel.updateMany(
|
||||
{ _id: { $in: projectId } },
|
||||
{ clientId: clientId }
|
||||
);
|
||||
results.projectUpdate = `Updated ${projectUpdateResult.modifiedCount} projects`;
|
||||
}
|
||||
|
||||
// Update VAs with clientId
|
||||
if (vaId && Array.isArray(vaId) && vaId.length > 0) {
|
||||
const vaUpdateResult = await VAModel.updateMany(
|
||||
{ _id: { $in: vaId } },
|
||||
{
|
||||
$addToSet: {
|
||||
clientIds: clientId
|
||||
}
|
||||
}
|
||||
);
|
||||
results.vaUpdate = `Updated ${vaUpdateResult.modifiedCount} VAs`;
|
||||
}
|
||||
|
||||
// Update Client with projectId and vaId
|
||||
const clientUpdateData = {};
|
||||
if (projectId && Array.isArray(projectId)) {
|
||||
clientUpdateData.projectIds = projectId;
|
||||
}
|
||||
if (vaId && Array.isArray(vaId)) {
|
||||
clientUpdateData.vaIds = vaId;
|
||||
}
|
||||
|
||||
if (Object.keys(clientUpdateData).length > 0) {
|
||||
const clientUpdate = await ClientModel.findByIdAndUpdate(
|
||||
clientId,
|
||||
{ $addToSet: clientUpdateData },
|
||||
{ new: true }
|
||||
);
|
||||
results.clientUpdate = clientUpdate ? 'Client updated successfully' : 'Client not found';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Relationships updated successfully',
|
||||
results
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating entity relationships:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
results: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove relationships (for deletion cases)
|
||||
* @param {Object} data - The removal data object
|
||||
* @returns {Object} - Result of the removal operations
|
||||
*/
|
||||
async function removeEntityRelationships(data) {
|
||||
try {
|
||||
let results = {};
|
||||
|
||||
// Handle Project removal
|
||||
if (data.project) {
|
||||
const { projectId, vaId, clientId } = data.project;
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error('Project ID is required for project removal');
|
||||
}
|
||||
|
||||
// Remove projectId from Client
|
||||
if (clientId) {
|
||||
await ClientModel.findByIdAndUpdate(
|
||||
clientId,
|
||||
{ $pull: { projectIds: projectId } }
|
||||
);
|
||||
}
|
||||
|
||||
// Remove projectId from VAs
|
||||
if (vaId && Array.isArray(vaId)) {
|
||||
await VAModel.updateMany(
|
||||
{ _id: { $in: vaId } },
|
||||
{ $pull: { projectIds: projectId } }
|
||||
);
|
||||
}
|
||||
|
||||
results.projectRemoval = 'Project relationships removed successfully';
|
||||
}
|
||||
|
||||
// Handle VA removal
|
||||
if (data.va) {
|
||||
const { vaId, projectId, clientId } = data.va;
|
||||
|
||||
if (!vaId) {
|
||||
throw new Error('VA ID is required for VA removal');
|
||||
}
|
||||
|
||||
// Remove vaId from Projects
|
||||
if (projectId && Array.isArray(projectId)) {
|
||||
await ProjectModel.updateMany(
|
||||
{ _id: { $in: projectId } },
|
||||
{ $pull: { vaAssigned: vaId } }
|
||||
);
|
||||
}
|
||||
|
||||
// Remove vaId from Clients
|
||||
if (clientId && Array.isArray(clientId)) {
|
||||
await ClientModel.updateMany(
|
||||
{ _id: { $in: clientId } },
|
||||
{ $pull: { vaIds: vaId } }
|
||||
);
|
||||
}
|
||||
|
||||
results.vaRemoval = 'VA relationships removed successfully';
|
||||
}
|
||||
|
||||
// Handle Client removal
|
||||
if (data.client) {
|
||||
const { clientId, projectId, vaId } = data.client;
|
||||
|
||||
if (!clientId) {
|
||||
throw new Error('Client ID is required for client removal');
|
||||
}
|
||||
|
||||
// Remove clientId from Projects
|
||||
if (projectId && Array.isArray(projectId)) {
|
||||
await ProjectModel.updateMany(
|
||||
{ _id: { $in: projectId } },
|
||||
{ clientId: null }
|
||||
);
|
||||
}
|
||||
|
||||
// Remove clientId from VAs
|
||||
if (vaId && Array.isArray(vaId)) {
|
||||
await VAModel.updateMany(
|
||||
{ _id: { $in: vaId } },
|
||||
{ $pull: { clientIds: clientId } }
|
||||
);
|
||||
}
|
||||
|
||||
results.clientRemoval = 'Client relationships removed successfully';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Relationships removed successfully',
|
||||
results
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error removing entity relationships:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
results: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { updateEntityRelationships, removeEntityRelationships };
|
||||
|
|
@ -90,13 +90,14 @@ const projectSchema = new Schema({
|
|||
ref: "VA",
|
||||
},
|
||||
],
|
||||
clientId: {
|
||||
type: Types.ObjectId,
|
||||
ref: "Client"
|
||||
},
|
||||
dedicatedHours: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["ACTIVE", "INACTIVE"],
|
||||
default: "ACTIVE"
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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"
|
||||
|
||||
// clients routes
|
||||
clientRoutes.post("/register", va_register_client); // Register a new VA
|
||||
|
|
@ -22,7 +23,8 @@ clientRoutes.get("/:clientId/projects/:projectId", va_clients_projects_callback)
|
|||
|
||||
|
||||
// dashboard API's
|
||||
clientRoutes.get("/dashboard", clientAuthValidator, client_dashboard); // VA Clients Projects Callback (NC)
|
||||
clientRoutes.get("/dashboard", clientAuthValidator, client_dashboard); // VA Clients Projects Callback
|
||||
clientRoutes.post("/dashboard/va-report", clientAuthValidator, client_dashboard_va_log); // VA Clients Projects Callback
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import { fileURLToPath } from 'url';
|
|||
import { dirname } from 'path';
|
||||
|
||||
import { process_reset } from "../../controllers/passwordReset.controllers.js";
|
||||
import { project_add_sf } from "../../controllers/project/projectAddSf.controller.js"
|
||||
import { update_project_sf } from "../../controllers/project/projectUpdateSf.controller.js"
|
||||
import { authValidator } from "../../middlewares/auth.middleware.js";
|
||||
|
||||
const v1Routes = express.Router()
|
||||
|
||||
|
|
@ -24,6 +27,9 @@ v1Routes.use("/auth_callback", authRoutes);
|
|||
v1Routes.use("/va", vaRoutes);
|
||||
v1Routes.use("/client", clientRoutes);
|
||||
|
||||
v1Routes.post("/project", authValidator, project_add_sf);
|
||||
v1Routes.patch("/project/:projectId", authValidator, update_project_sf);
|
||||
|
||||
v1Routes.post("/password/reset", process_reset);
|
||||
|
||||
v1Routes.get("/", function(req, res) {
|
||||
|
|
|
|||
154
src/server.js
154
src/server.js
|
|
@ -16,12 +16,25 @@ 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.set("view engine", "ejs");
|
||||
const PORT = process.env.PORT || 8000;
|
||||
|
||||
|
|
@ -41,11 +54,49 @@ app.use(bodyParser.text());
|
|||
// API Route
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
app.get('/varegister', (req, res) => {
|
||||
res.render('va_register', { title: "VA Registration" });
|
||||
app.get("/download-tracker", (req, res) => {
|
||||
res.render("download_tracker", {
|
||||
version: "3.2.1"
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/clientregister', (req, res) => {
|
||||
app.get("/download-file", (req, res) => {
|
||||
const os = req.query.os || "windows";
|
||||
|
||||
if (os === "windows") {
|
||||
return res.download("./files/docTracker-3.2.1.exe");
|
||||
} else {
|
||||
return res.download("./files/docTracker-3.2.1.dmg");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.get('/varegister/:sf_token?', (req, res) => {
|
||||
const { sf_token } = req.params;
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(sf_token, process.env.JWT_SECRET_SF_TOKEN);
|
||||
|
||||
if (decoded) {
|
||||
res.render('va_register', { title: "VA Registration" });
|
||||
} else {
|
||||
res.render('not_found', { title: "VA Registration" });
|
||||
}
|
||||
} catch (error) {
|
||||
res.render('not_found', { title: "VA Registration" });
|
||||
}
|
||||
|
||||
// if(verified) {
|
||||
// res.render('va_register', { title: "VA Registration" });
|
||||
// } else {
|
||||
// res.render('not_founf', { title: "VA Registration" });
|
||||
// }
|
||||
});
|
||||
|
||||
app.get('/clientregister/:sf_token?', (req, res) => {
|
||||
|
||||
// const { sf_token } = useParams();
|
||||
|
||||
res.render('va_client_register', { title: "Client Registration" });
|
||||
});
|
||||
|
||||
|
|
@ -64,25 +115,25 @@ app.get("/client/forgot-password", (req, res) => {
|
|||
|
||||
// Client Dashboard Routes with new file names
|
||||
app.get('/client/dashboard', async (req, res) => {
|
||||
try {
|
||||
// const dashboardData = await dashboardService.getClientDashboard(req.user.id);
|
||||
// res.render('client/dashboard-dashboard', dashboardData);
|
||||
res.render('client/dashboard-dashboard');
|
||||
} catch (error) {
|
||||
console.error('Dashboard error:', error);
|
||||
res.status(500).render('client/dashboard-dashboard', { error: 'Failed to load dashboard' });
|
||||
}
|
||||
try {
|
||||
// const dashboardData = await dashboardService.getClientDashboard(req.user.id);
|
||||
// res.render('client/dashboard-dashboard', dashboardData);
|
||||
res.render('client/dashboard-dashboard');
|
||||
} catch (error) {
|
||||
console.error('Dashboard error:', error);
|
||||
res.status(500).render('client/dashboard-dashboard', { error: 'Failed to load dashboard' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/client/virtual-assistants', async (req, res) => {
|
||||
try {
|
||||
// const vaData = await virtualAssistantService.getClientVAs(req.user.id);
|
||||
res.render('client/dashboard-virtual-assistants');
|
||||
// res.render('client/dashboard-virtual-assistants', vaData);
|
||||
} catch (error) {
|
||||
console.error('Virtual assistants error:', error);
|
||||
res.status(500).render('client/dashboard-virtual-assistants', { error: 'Failed to load virtual assistants' });
|
||||
}
|
||||
try {
|
||||
// const vaData = await virtualAssistantService.getClientVAs(req.user.id);
|
||||
res.render('client/dashboard-virtual-assistants');
|
||||
// res.render('client/dashboard-virtual-assistants', vaData);
|
||||
} catch (error) {
|
||||
console.error('Virtual assistants error:', error);
|
||||
res.status(500).render('client/dashboard-virtual-assistants', { error: 'Failed to load virtual assistants' });
|
||||
}
|
||||
});
|
||||
|
||||
// app.get('/client/timesheets', async (req, res) => {
|
||||
|
|
@ -115,9 +166,27 @@ app.get('/client/virtual-assistants', async (req, res) => {
|
|||
// }
|
||||
// });
|
||||
|
||||
app.get("/live/:projectId/:vaId", (req, res) => {
|
||||
const { projectId } = req.params;
|
||||
res.render("va_live_tracker", { title: "VA Live Tracker", projectId })
|
||||
app.get("/live/:projectId/:vaId", async (req, res) => {
|
||||
const { projectId, vaId } = req.params;
|
||||
|
||||
if (!projectId || !vaId) {
|
||||
return res.status(400).send({ "message": "Page not found" });
|
||||
}
|
||||
|
||||
let clientId = null;
|
||||
try {
|
||||
const clientRes = await ProjectModel.findById(projectId);
|
||||
if (clientRes) {
|
||||
clientId = clientRes.clientId.toString();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching client for project:", err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clientId) return res.status(400).send({ "message": "Page not found" });
|
||||
|
||||
res.render("va_live_tracker", { title: "VA Live Tracker", projectId, vaId, clientId })
|
||||
})
|
||||
|
||||
// Handling Socket
|
||||
|
|
@ -145,7 +214,7 @@ io.use(async (socket, next) => {
|
|||
return next(new Error("Invalid token payload"));
|
||||
}
|
||||
|
||||
/* ---------------------- 1⃣ Check if VA ---------------------- */
|
||||
/* ---------------------- 1⃣ Check if VA ---------------------- */
|
||||
let vaUser = null;
|
||||
let clientData = null;
|
||||
|
||||
|
|
@ -176,7 +245,7 @@ io.use(async (socket, next) => {
|
|||
return next();
|
||||
}
|
||||
|
||||
/* ---------------------- 2⃣ Check if Client ---------------------- */
|
||||
/* ---------------------- 2⃣ Check if Client ---------------------- */
|
||||
// const clientUser = await ClientModel.findOne({ "userDetails.email": decoded.email });
|
||||
|
||||
// if (clientUser) {
|
||||
|
|
@ -215,7 +284,7 @@ io.on("connection", (socket) => {
|
|||
console.log("User connected:", socket.id);
|
||||
|
||||
// Register role and ID (client or VA)
|
||||
socket.on("register", async ({ role, id }) => {
|
||||
socket.on("register", async ({ role, id, projectId = null, clientId = null }) => {
|
||||
if (role === "va") {
|
||||
socket.data.role = "va";
|
||||
socket.data.vaId = id;
|
||||
|
|
@ -224,10 +293,19 @@ io.on("connection", (socket) => {
|
|||
|
||||
if (role === "client") {
|
||||
socket.data.role = "client";
|
||||
socket.data.clientId = id;
|
||||
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(id)
|
||||
const client = await ClientModel.findById(clientId)
|
||||
.populate("projectIds") // to access their projects
|
||||
.populate("vaIds"); // optional if you track directly
|
||||
|
||||
|
|
@ -342,14 +420,7 @@ app.get("/client/forgot-password", (req, res) => {
|
|||
res.render("forgot_password")
|
||||
})
|
||||
|
||||
app.get("/live/:projectId", (req, res) => {
|
||||
const { projectId } = req.params;
|
||||
res.render("va_live_tracker", { title: "VA Live Tracker", projectId })
|
||||
})
|
||||
|
||||
// Handling Socket
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth?.token;
|
||||
|
|
@ -393,7 +464,7 @@ io.use(async (socket, next) => {
|
|||
|
||||
console.log(a, b, c, "vaProjClientId vs clientData.clientId");
|
||||
|
||||
vaSocketRespectToClient.set(vaProjClientId.toString(), socket.id)
|
||||
vaSocketRespectToClient.set(`${vaProjClientId.toString()}-${vaUser._id.toString()}`, socket.id)
|
||||
|
||||
console.log("✅ Authenticated VA:", vaUser.userDetails.email);
|
||||
return next();
|
||||
|
|
@ -501,10 +572,10 @@ io.on("connection", (socket) => {
|
|||
socket.on("selectApp", ({ appTitle, clientId }) => {
|
||||
console.log(`VA selected app: ${appTitle}`);
|
||||
|
||||
const sssrrrr = vaSocketRespectToClient.get(clientId);
|
||||
console.log(vaSocketRespectToClient.get(clientId), sssrrrr, "vaSocketRespectToClient[clientId]")
|
||||
const sssrrrr = vaSocketRespectToClient.get(`${clientId}-${socket.data.vaId}`);
|
||||
console.log(vaSocketRespectToClient.get(`${clientId}-${socket.data.vaId}`), sssrrrr, "vaSocketRespectToClient[clientId]")
|
||||
// You can forward this info to the respective Electron client
|
||||
socket.to(vaSocketRespectToClient.get(clientId)).emit("appToCapture", { appTitle });
|
||||
socket.to(vaSocketRespectToClient.get(`${clientId}-${socket.data.vaId}`)).emit("appToCapture", { appTitle });
|
||||
});
|
||||
|
||||
socket.on("getVisibleApps", async ({ token, apps }) => {
|
||||
|
|
@ -574,6 +645,13 @@ io.on("connection", (socket) => {
|
|||
// console.log(`Express started on http://localhost:${PORT}`);
|
||||
// });
|
||||
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).render("not_found", {
|
||||
title: "404 - Page Not Found"
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
mongoose
|
||||
.connect(
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@
|
|||
<th>Status</th>
|
||||
<th>Virtual Assistant</th>
|
||||
<th>Projects Assigned</th>
|
||||
<th>Worked Time</th>
|
||||
<th>Working from</th>
|
||||
<th>Total Worked</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -221,7 +222,7 @@
|
|||
|
||||
// Format time function
|
||||
function formatTime(seconds) {
|
||||
if (!seconds) return 'Not working';
|
||||
if (!seconds) return '';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
|
@ -230,6 +231,8 @@
|
|||
return `${hours}h ${minutes}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
} else if (seconds > 0) {
|
||||
return `${seconds}s`;
|
||||
} else {
|
||||
return 'Just started';
|
||||
}
|
||||
|
|
@ -240,6 +243,7 @@
|
|||
const statusClass = va.isCurrentlyActive ? 'active' : 'inactive';
|
||||
const statusText = va.isCurrentlyActive ? 'Active' : 'Inactive';
|
||||
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>
|
||||
|
|
@ -247,7 +251,7 @@
|
|||
</a>` : '';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<tr data-vaid="${va.id}">
|
||||
<td>
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot ${statusClass}"></div>
|
||||
|
|
@ -256,7 +260,7 @@
|
|||
</td>
|
||||
<td>
|
||||
<div class="va-info">
|
||||
<div class="va-name">${va.firstName} ${va.lastName}</div>
|
||||
<div class="va-name">${va.name}</div>
|
||||
<div class="va-email">${va.email || 'N/A'}</div>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -266,6 +270,9 @@
|
|||
<td>
|
||||
<span class="worked-time">${workedTime}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="worked-time">${totalWorkedTime}</span>
|
||||
</td>
|
||||
<td>
|
||||
${viewLink}
|
||||
</td>
|
||||
|
|
@ -303,15 +310,34 @@
|
|||
vaTableBody.style.display = '';
|
||||
vaEmptyState.style.display = 'none';
|
||||
|
||||
// Add event listeners to view links
|
||||
document.querySelectorAll('.view-link').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const vaId = this.getAttribute('data-va-id');
|
||||
// You can update this link as needed
|
||||
window.location.href = `/client/virtual-assistants/${vaId}`;
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} else {
|
||||
vaTableBody.innerHTML = '';
|
||||
vaTableBody.style.display = 'none';
|
||||
|
|
@ -351,6 +377,127 @@
|
|||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -96,47 +96,27 @@
|
|||
</aside>
|
||||
<main class="dashboard-main">
|
||||
<div class="dashboard-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Virtual Assistants</h1>
|
||||
<a href="/client/virtual-assistants/tips" class="action-button">
|
||||
<i class="fas fa-gift"></i>Manage Tips
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<h3>Active VAs</h3>
|
||||
<h3>Active VA</h3>
|
||||
<div class="stat-value" id="activeVaCount">0</div>
|
||||
</div>
|
||||
<div class="stat-icon blue">
|
||||
<i class="fas fa-users"></i>
|
||||
<i class="fas fa-users fa-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<h3>Unbilled Hours</h3>
|
||||
<div class="stat-value" id="unbilledHours">0</div>
|
||||
</div>
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<h3>Billed Hours</h3>
|
||||
<div class="stat-value" id="billedHours">0</div>
|
||||
<h3>Total VA's</h3>
|
||||
<div class="stat-value" id="totalVaCount">0</div>
|
||||
</div>
|
||||
<div class="stat-icon blue">
|
||||
<i class="fas fa-clock"></i>
|
||||
<i class="fas fa-users fa-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,143 +124,234 @@
|
|||
<div class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<h3>Billed Amount</h3>
|
||||
<div class="stat-value" id="billedAmount">$0.00</div>
|
||||
<h3>Total Projects</h3>
|
||||
<div class="stat-value" id="totalProjects">0</div>
|
||||
</div>
|
||||
<div class="stat-icon yellow">
|
||||
<i class="fas fa-dollar-sign"></i>
|
||||
<div class="stat-icon green">
|
||||
<i class="fas fa-clock fa-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Assistants Table -->
|
||||
<div class="table-container">
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Virtual Assistant</th>
|
||||
<th>Project</th>
|
||||
<th>Unbilled Hours</th>
|
||||
<th>Billed Hours</th>
|
||||
<th>Billed Tips</th>
|
||||
<th>Billed Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="vaTableBody">
|
||||
<!-- Data will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Virtual Assistants List -->
|
||||
<div class="content-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Virtual Assistants <span class="total-count" id="vaListCount">0 VAs</span></h2>
|
||||
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Virtual Assistant</th>
|
||||
<th>Projects Assigned</th>
|
||||
<th>Worked Time</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="vaTableBody">
|
||||
<!-- Data will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="vaEmptyState" style="display: none;">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-users"></i>
|
||||
<h3>No Virtual Assistants</h3>
|
||||
<p>You haven't assigned any virtual assistants yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function loadVirtualAssistants() {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_BASE}/virtual-assistants`);
|
||||
if (!response) return;
|
||||
<script>
|
||||
// Theme management
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// Update stats
|
||||
document.getElementById('activeVaCount').textContent = data.stats?.activeVaCount || 0;
|
||||
document.getElementById('unbilledHours').textContent = data.stats?.unbilledHours || '0';
|
||||
document.getElementById('billedHours').textContent = data.stats?.billedHours || '0';
|
||||
document.getElementById('billedAmount').textContent = `$${data.stats?.billedAmount || '0.00'}`;
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme') || getSystemTheme();
|
||||
setTheme(savedTheme);
|
||||
|
||||
// Update table
|
||||
const tableBody = document.getElementById('vaTableBody');
|
||||
if (data.virtualAssistants && data.virtualAssistants.length > 0) {
|
||||
tableBody.innerHTML = data.virtualAssistants.map(va => `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="status-badge status-active">${va.status}</span>
|
||||
</td>
|
||||
<td>${va.name}</td>
|
||||
<td>${va.project}</td>
|
||||
<td>
|
||||
<span class="hours-badge hours-default">
|
||||
${va.unbilledHours}<span class="hours-unit">hrs</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="hours-badge hours-primary">
|
||||
${va.billedHours || '-'}<span class="hours-unit">hr</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="amount-tips">$${va.billedTips || '0.00'}</td>
|
||||
<td class="amount-positive">$${va.billedAmount || '0.00'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
// API base URL
|
||||
const API_BASE = '/api/v1/client';
|
||||
|
||||
// Auth token management
|
||||
function getAuthToken() {
|
||||
return localStorage.getItem('knoptpq');
|
||||
}
|
||||
|
||||
async function fetchWithAuth(url, options = {}) {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
window.location.href = '/client/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'token': `${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('knoptpq');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/client/login';
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Format time function
|
||||
function formatTime(seconds) {
|
||||
if (!seconds) return 'Not working';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
} else {
|
||||
return 'Just started';
|
||||
}
|
||||
}
|
||||
|
||||
// Create VA card HTML
|
||||
function createVATableRow(va) {
|
||||
const statusClass = va.isCurrentlyActive ? 'active' : 'inactive';
|
||||
const statusText = va.isCurrentlyActive ? 'Active' : 'Inactive';
|
||||
const workedTime = formatTime(va.currentSessionDuration);
|
||||
const projectsAssigned = va.projectsAssigned || 0;
|
||||
const viewLink = va.isCurrentlyActive ? `<a href="/live/${va.lastProjectId}/${va.id}" class="view-link" data-va-id="${va.id}" target="_blank">
|
||||
<i class="fas fa-eye"></i>
|
||||
View
|
||||
</a>` : '';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot ${statusClass}"></div>
|
||||
<span class="status-text ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="va-info">
|
||||
<div class="va-name">${va.name}</div>
|
||||
<div class="va-email">${va.email || 'N/A'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="projects-count">${projectsAssigned} Projects</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="worked-time">${workedTime}</span>
|
||||
</td>
|
||||
<td>
|
||||
${viewLink}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// Load dashboard data
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_BASE}/dashboard`);
|
||||
if (!response) return;
|
||||
const result = await response.json();
|
||||
// console.log(data)
|
||||
|
||||
if (result.success) {
|
||||
const data = result.data;
|
||||
const summary = result.summary;
|
||||
|
||||
// Update stats cards
|
||||
document.getElementById('activeVaCount').textContent = summary.activeVAs || 0;
|
||||
document.getElementById('totalVaCount').textContent = summary.totalVAs || 0;
|
||||
document.getElementById('totalProjects').textContent = summary.totalProjects || 0;
|
||||
|
||||
// Update VA list count
|
||||
document.getElementById('vaListCount').textContent = `${summary.totalVAs || 0} VAs`;
|
||||
|
||||
// Update VA table
|
||||
const vaTableBody = document.getElementById('vaTableBody');
|
||||
const vaEmptyState = document.getElementById('vaEmptyState');
|
||||
const allVAs = data.allVAs?.total || [];
|
||||
|
||||
if (allVAs.length > 0) {
|
||||
vaTableBody.innerHTML = allVAs.map(va => createVATableRow(va)).join('');
|
||||
vaTableBody.style.display = '';
|
||||
vaEmptyState.style.display = 'none';
|
||||
|
||||
// Add event listeners to view links
|
||||
document.querySelectorAll('.view-link').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const vaId = this.getAttribute('data-va-id');
|
||||
// You can update this link as needed
|
||||
window.location.href = `/client/virtual-assistants/${vaId}`;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="empty-state">No virtual assistants found</td>
|
||||
</tr>
|
||||
`;
|
||||
vaTableBody.innerHTML = '';
|
||||
vaTableBody.style.display = 'none';
|
||||
vaEmptyState.style.display = 'block';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading virtual assistants:', error);
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadVirtualAssistants);
|
||||
</script>
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
const vaTableBody = document.getElementById('vaTableBody');
|
||||
const vaEmptyState = document.getElementById('vaEmptyState');
|
||||
|
||||
vaTableBody.style.display = 'none';
|
||||
vaEmptyState.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<h3>Error</h3>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
`;
|
||||
vaEmptyState.style.display = 'block';
|
||||
}
|
||||
|
||||
// Load data when page loads
|
||||
document.addEventListener('DOMContentLoaded', loadDashboardData);
|
||||
|
||||
// Optional: Auto-refresh data every 30 seconds
|
||||
setInterval(loadDashboardData, 30000);
|
||||
|
||||
|
||||
<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('authToken');
|
||||
// }
|
||||
|
||||
// async function fetchWithAuth(url, options = {}) {
|
||||
// const token = getAuthToken();
|
||||
// if (!token) {
|
||||
// window.location.href = '/client/login';
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const response = await fetch(url, {
|
||||
// ...options,
|
||||
// headers: {
|
||||
// 'Authorization': `Bearer ${token}`,
|
||||
// 'Content-Type': 'application/json',
|
||||
// ...options.headers,
|
||||
// }
|
||||
// });
|
||||
|
||||
// if (response.status === 401) {
|
||||
// localStorage.removeItem('authToken');
|
||||
// window.location.href = '/client/login';
|
||||
// return;
|
||||
// }
|
||||
|
||||
// return response;
|
||||
// }
|
||||
</script>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Download DocTracker</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* align-items: center; */
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
color: #111;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 630px;
|
||||
}
|
||||
|
||||
h1 span {
|
||||
color: #1a168b;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.os-select {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.os-btn {
|
||||
padding: 12px 22px;
|
||||
border-radius: 30px;
|
||||
border: 2px solid #ddd;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.os-btn img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.active img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.os-btn.active {
|
||||
background: #0a0670;
|
||||
color: #fff;
|
||||
border-color: #0a0670;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.download-btn {
|
||||
margin: 0 auto;
|
||||
padding: 14px 25px;
|
||||
background: #d4d7f7;
|
||||
color: #0a0670;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.download-btn img {
|
||||
filter: brightness(0) invert(1);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
#downloadBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 15px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: #c5c8f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Download <span>DocTracker</span></h1>
|
||||
|
||||
<p class="desc">
|
||||
Let your clients see your real-time work logs, ensuring<br />
|
||||
you get paid accurately for every minute you've put in.
|
||||
</p>
|
||||
|
||||
<div class="os-select">
|
||||
<div class="os-btn active" data-os="windows">
|
||||
<img src="/public/images/windows.png" alt="windows">
|
||||
<span>Windows</span>
|
||||
</div>
|
||||
<div class="os-btn" data-os="mac">
|
||||
<img src="/public/images/mac-os-logo.png" alt="macos">
|
||||
<span>MacOS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="download-btn">
|
||||
<!-- <img src="/public/images/downloads.png" alt="macos" > -->
|
||||
<button id="downloadBtn">
|
||||
Download Windows version 3.2.1
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const osButtons = document.querySelectorAll(".os-btn");
|
||||
const downloadBtn = document.getElementById("downloadBtn");
|
||||
const version = "<%= version || '3.2.1' %>";
|
||||
|
||||
let selectedOS = "windows";
|
||||
|
||||
// -------- Auto OS Detection --------
|
||||
const platform = navigator.userAgent.toLowerCase();
|
||||
if (platform.includes("mac")) selectedOS = "mac";
|
||||
else selectedOS = "windows";
|
||||
|
||||
// Apply pre-selection
|
||||
osButtons.forEach(btn => {
|
||||
btn.classList.toggle("active", btn.dataset.os === selectedOS);
|
||||
});
|
||||
|
||||
// Update button text
|
||||
downloadBtn.innerHTML =
|
||||
`Download ${selectedOS === 'windows' ? 'Windows' : 'MacOS'} version ${version}`;
|
||||
|
||||
// -------- Manual Selection by User --------
|
||||
osButtons.forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
osButtons.forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
selectedOS = btn.dataset.os;
|
||||
|
||||
downloadBtn.innerHTML =
|
||||
`Download ${selectedOS === 'windows' ? 'Windows' : 'MacOS'} version ${version}`;
|
||||
});
|
||||
});
|
||||
|
||||
// -------- Download --------
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
window.location.href = `/download-file?os=${selectedOS}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>404 - Page Not Found</title>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--text: #222222;
|
||||
--subtext: #555555;
|
||||
--button-bg: #6c63ff;
|
||||
--button-text: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark mode automatic */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--text: #ffffff;
|
||||
--subtext: #bbbbbb;
|
||||
--button-bg: #6c63ff;
|
||||
--button-text: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 7rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
margin-top: 10px;
|
||||
color: var(--subtext);
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-top: 30px;
|
||||
padding: 14px 26px;
|
||||
background: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 150px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 480px) {
|
||||
h1 {
|
||||
font-size: 4.5rem;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<img src="/public/images/logo-white-vertical.svg" alt="404 Icon">
|
||||
<h1>404</h1>
|
||||
<p>Oops! The page you're looking for does not exist.</p>
|
||||
<a href="/" class="btn">Go Home</a>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function applyTheme() {
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const logo = document.querySelector("img");
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
logo.src = "/public/images/logo-white-vertical.svg"; // Dark logo for light mode
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
logo.src = "/public/images/logo-colored-vertical.svg"; // White logo for dark mode
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme on load
|
||||
applyTheme();
|
||||
|
||||
// Listen for OS theme changes
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -3,141 +3,282 @@
|
|||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Live Screen Feed</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
<title>Live Screen Feed</title>
|
||||
|
||||
body {
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-family: 'Inter', sans-serif;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
background: #0f0f0f;
|
||||
color: white;
|
||||
font-family: "Inter", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* SIDEBAR */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: #1b1b1b;
|
||||
border-right: 1px solid #333;
|
||||
background: #181818;
|
||||
border-right: 1px solid #2b2b2b;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
transition: transform 0.35s ease;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app {
|
||||
background: #222;
|
||||
background: #252525;
|
||||
border: 1px solid #333;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
word-break: break-word;
|
||||
transition: 0.3s ease;
|
||||
}
|
||||
|
||||
.app:hover {
|
||||
background: #333;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.app.active {
|
||||
background: #6C63FF;
|
||||
background: #6c63ff;
|
||||
border-color: #6c63ff;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px rgba(108, 99, 255, 0.5);
|
||||
}
|
||||
|
||||
/* MAIN CONTENT */
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 12px;
|
||||
#selectedAppTitle {
|
||||
font-size: 20px;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
#liveImage {
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
border: 2px solid #555;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #444;
|
||||
object-fit: contain;
|
||||
background: black;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-image {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
.placeholder {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
color: #bbb;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* MOBILE RESPONSIVE */
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
background: #6C63FF;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
z-index: 9999;
|
||||
/* VERY HIGH: always visible */
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.mobile-toggle span {
|
||||
display: block;
|
||||
width: 26px;
|
||||
height: 3px;
|
||||
background: #fff;
|
||||
margin: 5px 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Sidebar Hidden on Mobile */
|
||||
@media (max-width: 780px) {
|
||||
body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
/* Dark overlay when sidebar opens */
|
||||
#overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: none;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
#overlay.show {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
|
||||
<!-- Mobile Toggle Button -->
|
||||
<div class="mobile-toggle" id="toggleBtn">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
<div id="overlay"></div>
|
||||
|
||||
<div class="sidebar" id="sidebar">
|
||||
<h3>Visible Apps</h3>
|
||||
<div id="appsList"></div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h2 id="selectedAppTitle">Select an app to view</h2>
|
||||
<img id="liveImage" src="" alt="Waiting for image..." class="no-image" />
|
||||
<img id="liveImage" alt="Live Feed" />
|
||||
<div id="placeholder" class="placeholder">No app selected</div>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script>
|
||||
const clientId = "691311fece83c986eb8a4e89";
|
||||
const socket = io({
|
||||
auth: {
|
||||
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY5MTQ2NTFkODM2NDZhOTAwN2EzNDI4MCIsImVtYWlsIjoiYmhhZ3dhbi5yQGhpY2dsb2JhbHNvbHV0aW9ucy5jb20iLCJuYW1lIjoiYmhhZ3dhbiIsImlhdCI6MTc2Mjk0ODk3NSwiZXhwIjoxNzYzMDM1Mzc1fQ.WvmTvfOf8DvbqKsbvfyzda1ClP8qfllKFeJINtU6mnE",
|
||||
userType: "client",
|
||||
projectId: "<%= projectId %>"
|
||||
},
|
||||
});
|
||||
<script>
|
||||
const vaId = "<%= vaId %>";
|
||||
const clientId = "<%= clientId %>";
|
||||
const token = localStorage.getItem("knoptpq");
|
||||
|
||||
socket.emit("register", { role: "client", id: clientId, projectId: "<%= projectId %>" });
|
||||
const socket = io({
|
||||
auth: {
|
||||
token: token,
|
||||
userType: "client",
|
||||
projectId: "<%= projectId %>"
|
||||
},
|
||||
});
|
||||
|
||||
const appsList = document.getElementById("appsList");
|
||||
const liveImage = document.getElementById("liveImage");
|
||||
const selectedAppTitle = document.getElementById("selectedAppTitle");
|
||||
let currentApp = null;
|
||||
socket.emit("register", {
|
||||
role: "client",
|
||||
id: vaId,
|
||||
projectId: "<%= projectId %>",
|
||||
clientId: clientId
|
||||
});
|
||||
|
||||
// 🟢 Receive visible apps from client
|
||||
socket.on("receiveVisibleApps", (apps) => {
|
||||
appsList.innerHTML = "";
|
||||
if (!apps?.length) {
|
||||
appsList.innerHTML = "<p>No visible apps</p>";
|
||||
return;
|
||||
}
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const toggleBtn = document.getElementById("toggleBtn");
|
||||
const overlay = document.getElementById("overlay");
|
||||
|
||||
apps.forEach(app => {
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("app");
|
||||
div.textContent = app.name || "Unnamed App";
|
||||
div.onclick = () => {
|
||||
document.querySelectorAll(".app").forEach(el => el.classList.remove("active"));
|
||||
div.classList.add("active");
|
||||
currentApp = app.name;
|
||||
selectedAppTitle.textContent = app.name;
|
||||
const appsList = document.getElementById("appsList");
|
||||
const liveImage = document.getElementById("liveImage");
|
||||
const selectedAppTitle = document.getElementById("selectedAppTitle");
|
||||
const placeholder = document.getElementById("placeholder");
|
||||
|
||||
// 🟠 Tell server which app VA wants to monitor
|
||||
socket.emit("selectApp", { appTitle: app.name, clientId });
|
||||
};
|
||||
appsList.appendChild(div);
|
||||
});
|
||||
});
|
||||
let currentApp = null;
|
||||
|
||||
// 🟣 Receive images only for the selected app
|
||||
socket.on("receiveImage", (base64, title) => {
|
||||
if (currentApp && title === currentApp) {
|
||||
liveImage.src = base64;
|
||||
liveImage.classList.remove("no-image");
|
||||
}
|
||||
});
|
||||
/* MOBILE SIDEBAR TOGGLE */
|
||||
toggleBtn.onclick = () => {
|
||||
sidebar.classList.add("open");
|
||||
overlay.classList.add("show");
|
||||
};
|
||||
|
||||
overlay.onclick = () => {
|
||||
sidebar.classList.remove("open");
|
||||
overlay.classList.remove("show");
|
||||
};
|
||||
|
||||
/* Receive visible apps */
|
||||
socket.on("receiveVisibleApps", (apps) => {
|
||||
appsList.innerHTML = "";
|
||||
|
||||
if (!apps?.length) {
|
||||
appsList.innerHTML = "<p style='color:#777;'>No visible apps</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
apps.forEach(app => {
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("app");
|
||||
div.textContent = app.name || "Unnamed App";
|
||||
|
||||
div.onclick = () => {
|
||||
document.querySelectorAll(".app").forEach(el => el.classList.remove("active"));
|
||||
div.classList.add("active");
|
||||
|
||||
currentApp = app.name;
|
||||
selectedAppTitle.textContent = app.name;
|
||||
|
||||
placeholder.style.display = "none";
|
||||
liveImage.style.display = "none";
|
||||
|
||||
socket.emit("selectApp", { appTitle: app.name, clientId, vaId });
|
||||
|
||||
// Close sidebar on mobile
|
||||
sidebar.classList.remove("open");
|
||||
overlay.classList.remove("show");
|
||||
};
|
||||
|
||||
appsList.appendChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("authError", () => {
|
||||
socket.disconnect();
|
||||
localStorage.removeItem("knoptpq");
|
||||
window.location.href = "/client/login";
|
||||
});
|
||||
|
||||
/* Receive images */
|
||||
socket.on("receiveImage", (base64, title) => {
|
||||
if (currentApp && title === currentApp) {
|
||||
liveImage.src = base64;
|
||||
liveImage.style.display = "block";
|
||||
placeholder.style.display = "none";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
Reference in New Issue