va-tracker/src/ui/layout/DashboardLayout.tsx

802 lines
27 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from "react";
import io, { Socket } from 'socket.io-client';
import {
Outlet,
NavLink,
useNavigate,
useLocation,
} from "react-router-dom";
import {
Bell,
Menu,
LogOut,
User,
Settings,
Home,
FolderKanban,
X,
CalendarDays,
SettingsIcon,
TimerIcon,
HelpCircleIcon,
Pause,
ShieldBan,
ChevronDown,
Play,
ChevronUp,
GlobeLock,
ClipboardClock,
CircleDollarSign,
} from "lucide-react";
import { useActiveAppTracker } from "../hooks/useActiveAppTracker";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../store";
import { initializeProjectTracking, setProjectsDataSlice, setProjectSummarySlice, setSelectedProjectSlice, startTracking, stopTrackingReducer, updateElapsedTime, updateProjectTracking } from "../store/projectSlice";
import { clearUser } from "../store/userSlice";
import { setError, setLoading, setProfile } from "../store/profileSlice";
import { canStartTracking } from "../utils/projectLimits";
import { useTheme } from "../helper/ThemeToggle";
import LogoutModal from "../modal/LogoutModal";
import { parseTimeStringToSeconds } from "../utils/timeParser";
interface User {
id: string;
name: string;
email: string;
}
interface Project {
projectId: string;
projectName: string;
activityStatus: "ACTIVE" | "AWAY";
totalWorkTime: string;
totalWorkMs: number;
totalLogs: number;
dedicatedHours: string;
}
interface ProjectTrackingData {
elapsedTime: number;
startTime: Date | null;
endTime: Date | null;
isTracking: boolean;
isPaused: boolean;
pauseStartTime: Date | null;
totalPausedTime: number;
accumulatedTime: number;
}
export interface TrackingState {
selectedProject: Project | null;
isTracking: boolean;
isPaused: boolean;
elapsedTime: number;
startTime: Date | null;
endTime: Date | null;
handleStartTracking: (project: Project) => void;
handlePauseResume: () => void;
handleStopTracking: () => void;
handleProjectSelect: (projectId: string) => void;
projectsData: Project[];
loadingStart: boolean,
loadingStop: boolean,
isStopping: boolean
}
const DashboardLayout = () => {
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
const [isSidebarOpen, setSidebarOpen] = useState(true);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const [isStopping, setIsStopping] = useState(false);
const { projectsData, projectSummary, selectedProject, projectTrackingMap } =
useSelector((state: any) => state.projects);
const tracking = selectedProject
? projectTrackingMap[selectedProject.projectId]
: null;
const isTracking = tracking?.isTracking;
const elapsedTime = tracking?.elapsedTime || 0;
const { windowLogs } = useSelector((state: RootState) => state.window);
const menuItems = [
{ label: "Dashboard", path: "/dashboard", icon: <Home size={18} />, exact: true },
{ label: "Timesheet", path: "/dashboard/timesheet", icon: <TimerIcon size={18} /> },
{ label: "Leave & Adjust", path: "/dashboard/leave-adjustments", icon: <CalendarDays size={18} /> },
{ label: "Schedule", path: "/dashboard/schedule", icon: <ClipboardClock size={18} /> },
{ label: "Pays Slips", path: "/dashboard/pays-slips", icon: <CircleDollarSign size={18} /> },
{ label: "Settings", path: "/dashboard/settings", icon: <SettingsIcon size={18} /> },
{ label: "VPN", path: "/dashboard/vpn", icon: <GlobeLock size={18} /> },
{ label: "Help & Support", path: "/dashboard/help-support", icon: <HelpCircleIcon size={18} /> },
];
const [isPaused, setIsPaused] = useState(false);
const [startTime, setStartTime] = useState<Date | null>(null);
const [endTime, setEndTime] = useState<Date | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [activeProjectLogId, setActiveProjectLogId] = useState("");
const toggleWindowSize = useRef(true);
const [loadingStart, setLoadingStart] = useState(false);
const [loadingStop, setLoadingStop] = useState(false);
const [showLogoutModal, setShowLogoutModal] = useState(false);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
// Create a ref to store the stop function so it can be called from the hook
const stopTrackingRef = useRef<(() => Promise<void>) | null>(null);
// Define handleStopTracking FIRST
const handleStopTracking = useCallback(async () => {
if (loadingStop || loadingStart) return;
if (!selectedProject) {
console.warn("No selected project to stop");
return;
}
let currentStopTime = new Date().toISOString();
const projectId = selectedProject.projectId;
console.log(" Stop tracking initiated for:", selectedProject.projectName);
console.log(" Current elapsed time:", elapsedTime);
setIsStopping(true);
setLoadingStop(true);
handleSocketDisconnect();
// STEP 1: Clear timer FIRST to prevent further client-side updates
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
console.log("Timer cleared");
}
// STEP 2: Send final PATCH with last active window data
if (stopTrackingRef.current) {
try {
console.log(" Sending final PATCH to record last activity...");
await stopTrackingRef.current();
console.log(" Final tracking data sent successfully");
} catch (error) {
console.error(" Error sending final tracking data:", error);
}
}
// STEP 3: Send LOGOUT to backend
try {
const payload = {
vaId: projectSummary.vaId,
projectId: projectId,
type: "LOGOUT",
time: currentStopTime
};
console.log("📡 Sending LOGOUT request:", payload);
const res = await window.electron.stopProjectTracking(payload);
console.log(" LOGOUT successful:", res);
} catch (error) {
console.error(" Failed to send LOGOUT:", error);
}
// STEP 4: Update Redux tracking state to stop tracking
console.log("🔄 Updating Redux state to stop tracking...");
dispatch(
stopTrackingReducer({
projectId,
})
);
// Allow React to process the state update
await new Promise(resolve => setTimeout(resolve, 50));
// STEP 5: Refresh project list to get updated work hours from server
try {
console.log("🔄 Refreshing project list...");
const projectListRes = await window.electron.getProjectList();
console.log("📊 Updated project list:", projectListRes);
if (projectListRes?.success && Array.isArray(projectListRes.data)) {
// Update projects data
dispatch(setProjectsDataSlice(projectListRes.data));
// Update project summary
dispatch(
setProjectSummarySlice({
totalProjects: projectListRes.totalProjects || 0,
activeProjects: projectListRes.activeProjects || 0,
awayProjects: projectListRes.awayProjects || 0,
totalWorkTime: projectListRes.totalWorkTime || "0h 0m 0s",
totalWorkMs: projectListRes.totalWorkMs || 0,
vaName: projectListRes.vaName || "",
vaId: projectListRes.vaId || "",
})
);
// Update selected project with fresh data
const updatedProject = projectListRes.data.find(
(p: Project) => p.projectId === projectId
);
if (updatedProject) {
dispatch(setSelectedProjectSlice(updatedProject));
// CRITICAL FIX: Use server time instead of client elapsedTime
const serverElapsedTime = parseTimeStringToSeconds(updatedProject.totalWorkTime);
console.log("🔄 Syncing client time with server time:", {
serverTime: updatedProject.totalWorkTime,
serverElapsedSeconds: serverElapsedTime,
previousClientTime: elapsedTime,
difference: serverElapsedTime - elapsedTime
});
// Force update the tracking map with server time
dispatch(
updateProjectTracking({
projectId: updatedProject.projectId,
data: {
elapsedTime: serverElapsedTime,
accumulatedTime: serverElapsedTime,
isTracking: false,
isPaused: false,
startTime: null,
endTime: Date.now(),
pauseStartTime: null,
totalPausedTime: 0
}
})
);
console.log("✅ Selected project updated with server time");
}
}
// STEP 6: Update UI (window restore)
await window.electron.toggleCloseBtn(true);
await window.electron.resizeWindow(500, 600);
toggleWindowSize.current = true;
} catch (error) {
console.error("❌ Error refreshing project list:", error);
} finally {
// Clear loading states
setLoadingStop(false);
setIsStopping(false);
console.log("🎉 TRACKING STOPPED SUCCESSFULLY");
}
}, [selectedProject, projectSummary, elapsedTime, dispatch]);
// NOW define handleLimitExceeded using the defined handleStopTracking
const handleLimitExceeded = useCallback(async () => {
console.log("🚨 LIMIT EXCEEDED - Auto-stopping tracking");
if (!selectedProject) {
console.error(" No selected project for limit exceeded");
return;
}
console.log("Alert Message 1")
// Show alert to user
alert(
`⏰ Daily Limit Reached!\n\n` +
`Project: ${selectedProject.projectName}\n` +
`Dedicated Hours: ${selectedProject.dedicatedHours}\n\n` +
`Your tracking has been stopped automatically because you've reached the daily time limit for this project.`
);
console.log("Alert Message 2")
// Stop tracking
// await handleStopTracking();
}, [selectedProject, handleStopTracking]);
// Initialize useActiveAppTracker and store finalizeTracking in ref
const { finalizeTracking } = useActiveAppTracker(
isTracking,
activeProjectLogId,
selectedProject?.projectId ?? null,
selectedProject?.projectName ?? null,
handleLimitExceeded
);
// Update the ref whenever finalizeTracking changes
useEffect(() => {
stopTrackingRef.current = finalizeTracking;
}, [finalizeTracking]);
const socketRef = useRef<Socket | null>(null);
const intervalRef = useRef<number | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
if (!window.electron?.onTokenExpired) return;
console.log("🔐 Token expiry listener registered");
window.electron.onTokenExpired(async () => {
console.log("🔥 Token expired — checking tracking status...");
// If user is tracking → FIRST stop tracking
if (isTracking && handleStopTracking) {
console.log("⛔ Token expired DURING tracking → stopping tracking first...");
try {
await handleStopTracking(); // Wait until stop is completed
console.log("🛑 Tracking stopped successfully before logout");
} catch (err) {
console.error(" Error while stopping tracking after token expiry:", err);
}
}
// Then perform logout
console.log("🚪 Proceeding to logout due to token expiry...");
await handleLogout();
});
return () => {
console.log("🧹 Cleaning token-expired listener");
};
}, [isTracking, handleStopTracking]);
const handleLogout = async () => {
try {
const res = await window.electron.logout();
if (res) {
dispatch(clearUser());
navigate("/login");
}
} catch (error) {
console.error("Logout error:", error);
}
};
const selectedData = selectedProject
? projectTrackingMap[selectedProject.projectId]
: null;
const handleStartTracking = async (project: Project) => {
if (loadingStart || loadingStop) return;
// Check limit before starting
const limitCheck = canStartTracking(project);
if (!limitCheck.canStart) {
alert(
`⚠️ Cannot Start Tracking!\n\n` +
`Project: ${project.projectName}\n` +
`Reason: ${limitCheck.reason}\n\n` +
`You've already completed your daily hours for this project.`
);
return;
}
console.log(`⏰ Starting tracking. Remaining time: ${limitCheck.remaining}`);
setLoadingStart(true);
const now = Date.now();
let currentStartTime = new Date().toISOString();
window.electron.toggleCloseBtn(false);
const payload = {
vaId: projectSummary.vaId,
projectId: project.projectId,
type: "LOGIN",
time: currentStartTime
};
try {
const res = await window.electron.startProjectTracking(payload);
if (!res.success) {
alert(res.message);
await window.electron.toggleCloseBtn(true);
return;
}
// CRITICAL FIX: Get the current accumulated time BEFORE starting timer
const currentAccumulatedTime = selectedData?.accumulatedTime || parseTimeStringToSeconds(project.totalWorkTime);
console.log("📊 Starting with accumulated time:", {
accumulatedTime: currentAccumulatedTime,
projectTotalWorkTime: project.totalWorkTime,
parsedTime: parseTimeStringToSeconds(project.totalWorkTime)
});
// ⬇️ REDUX UPDATE: Start Tracking
dispatch(
startTracking({
projectId: project.projectId,
startTime: now,
})
);
handleSocketConnect();
setActiveProjectLogId(res?.data?.logId);
console.log(" Tracking started. Log ID:", res?.data?.logId);
// ⬇️ Calculate time limit in seconds
const parseTimeToSeconds = (timeStr: string): number => {
return parseTimeStringToSeconds(timeStr);
};
const dedicatedSeconds = parseTimeToSeconds(project.dedicatedHours);
const alreadyWorkedSeconds = parseTimeStringToSeconds(project.totalWorkTime);
const remainingSeconds = dedicatedSeconds - alreadyWorkedSeconds;
console.log("⏱️ Time Limits:", {
dedicatedHours: project.dedicatedHours,
dedicatedSeconds,
alreadyWorked: `${Math.floor(alreadyWorkedSeconds / 60)}m ${alreadyWorkedSeconds % 60}s`,
remainingSeconds,
remainingMinutes: Math.floor(remainingSeconds / 60)
});
// ⬇️ FIXED: Start timer with proper initial time
// Set initial elapsed time immediately to avoid jump
dispatch(
updateElapsedTime({
projectId: project.projectId,
elapsedTime: currentAccumulatedTime,
})
);
// Start global timer loop with limit check
timerRef.current = setInterval(() => {
const timeSinceStart = Math.floor((Date.now() - now) / 1000);
const currentElapsed = currentAccumulatedTime + timeSinceStart;
dispatch(
updateElapsedTime({
projectId: project.projectId,
elapsedTime: currentElapsed,
})
);
// Check if time limit reached
const totalTimeWorked = alreadyWorkedSeconds + timeSinceStart;
if (totalTimeWorked >= dedicatedSeconds) {
console.warn("🚨 CLIENT-SIDE: Time limit reached!");
console.log(`Total worked: ${Math.floor(totalTimeWorked / 60)}m vs Limit: ${Math.floor(dedicatedSeconds / 60)}m`);
// Clear timer first to prevent multiple triggers
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Trigger auto-stop
handleLimitExceeded();
handleSocketDisconnect();
}
}, 1000);
} catch (error) {
console.error(" Failed to sync project start:", error);
alert("Something went wrong!");
await window.electron.toggleCloseBtn(true);
} finally {
setLoadingStart(false);
}
};
const handlePauseResume = () => {
if (!selectedProject) return;
// Implement pause/resume logic if needed
};
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
const handleProjectSelect = (projectId: string) => {
const project = projectsData.find((p: any) => p.projectId === projectId);
if (!project) return;
dispatch(setSelectedProjectSlice(project));
const existing = projectTrackingMap[projectId];
if (!existing) {
// Use parsed totalWorkTime instead of totalWorkMs
const initialTime = parseTimeStringToSeconds(project.totalWorkTime);
dispatch(initializeProjectTracking({
projectId,
initialTime: initialTime
}));
}
};
const handleSocketConnect = async () => {
const token = await window.electron.getToken("ACCESS_TOKEN");
if (socketRef.current) {
console.warn("Already connected");
return;
}
if (!selectedProject?.projectId) return
console.log(selectedProject?.projectId, "SelectedProject ID")
socketRef.current = io("https://daniela-nonprofessed-boastfully.ngrok-free.app", {
transports: ["websocket"],
secure: true,
rejectUnauthorized: false,
reconnection: true,
withCredentials: true,
forceNew: true,
extraHeaders: {
"Access-Control-Allow-Origin": "*"
},
auth: {
token: token,
projectId: selectedProject?.projectId,
userType: "va"
},
});
socketRef.current.on("connect", () => {
console.log("🟢 Connected:", socketRef.current?.id);
setIsConnected(true);
// Start sending every 5 seconds
intervalRef.current = setInterval(async () => {
const apps = await window.electron.getRunningApps()
socketRef.current?.emit("getVisibleApps", { token: await token, apps });
}, 5000);
});
socketRef.current.on("appToCapture", async ({ appTitle }) => {
console.log("📸 Capture requested for:", appTitle);
const base64String = await window.electron.captureAppScreenShot(appTitle);
if (base64String && socketRef.current) {
socketRef.current.emit("sendImage", {
token: token,
image: base64String,
title: appTitle,
projectId: selectedProject?.projectId,
});
}
});
socketRef.current.on("disconnect", () => {
console.log("🔴 Disconnected");
setIsConnected(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
});
};
const handleSocketDisconnect = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
console.log("🔌 Disconnected manually");
}
};
const trackingState: TrackingState = {
selectedProject,
isTracking,
isPaused,
elapsedTime,
startTime,
endTime,
handleStartTracking,
handlePauseResume,
handleStopTracking,
projectsData,
handleProjectSelect,
loadingStart,
loadingStop,
isStopping
};
const formatTime = (seconds: number) => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hrs}h ${mins}m ${secs}s`;
// return `${hrs}h ${mins}m `;
};
return (
<>
<div className="flex flex-col h-screen overflow-hidden">
{/* Header */}
{isTracking &&
<header className="w-full flex justify-between items-center px-4 py-2 bg-base-100 border-b border-base-300 sticky top-0 z-30">
<div className="flex items-center space-x-3">
{isStopping ? (
// Skeleton for left side during stop
<div className="flex items-center space-x-3">
<div className="skeleton w-8 h-8 rounded-full"></div>
<div className="skeleton w-32 h-4"></div>
</div>
) : selectedProject ? (
isTracking ? (
<button
className="bg-error text-white p-1.5 rounded-full flex items-center justify-center cursor-pointer"
onClick={handleStopTracking}
disabled={loadingStop}
title="Stop Tracking"
>
{loadingStop ? (
<span className="loading loading-spinner loading-xs text-white"></span>
) : (
<Pause size={16} />
)}
</button>
) : (
<button
className="bg-success text-white p-1.5 rounded-full flex items-center justify-center cursor-pointer"
onClick={() => handleStartTracking(selectedProject)}
title="Start Tracking"
>
<Play size={16} />
</button>
)
) : (
<button
className="bg-gray-400 text-white p-1.5 rounded-full flex items-center justify-center cursor-not-allowed"
disabled
title="Select a project to start"
>
<Play size={16} />
</button>
)}
{!isStopping && selectedProject ? (
<span className="ml-2 text-sm text-base-content/70">
{selectedProject.projectName}
</span>
) : !isStopping ? (
<span className="ml-2 text-sm text-base-content/40 italic">
No Project Selected
</span>
) : null}
</div>
<div className="flex items-center space-x-3">
{isStopping ? (
// Skeleton for right side during stop
<div className="flex items-center space-x-3">
<div className="skeleton w-20 h-6 rounded"></div>
<div className="skeleton w-6 h-6 rounded"></div>
<div className="skeleton w-8 h-8 rounded"></div>
</div>
) : (
<>
<div className="badge badge-success text-white font-semibold px-3 py-2 shadow-sm">
{formatTime(
isTracking ? elapsedTime : parseTimeStringToSeconds(selectedProject?.totalWorkTime || "0h 0m 0s")
)}
</div>
{/* <span className="text-error">
<ShieldBan size={18} />
</span> */}
<button
onClick={() => {
if (toggleWindowSize.current) {
window.electron.resizeWindow(500, 85);
toggleWindowSize.current = false;
} else {
window.electron.resizeWindow(500, 600);
toggleWindowSize.current = true;
}
}}
className="btn btn-xs btn-outline btn-primary"
>
{toggleWindowSize.current ? <ChevronDown /> : <ChevronUp />}
</button>
</>
)}
</div>
</header>
}
{/* Layout Body */}
<div className={`flex flex-1 bg-base-200 overflow-hidden`}>
{/* Sidebar */}
<aside className="flex-none w-16 border-r border-base-300 bg-base-100 flex flex-col items-center py-4 gap-2">
{menuItems.map((item) => (
<div className="tooltip z-100 tooltip-right " key={item.label} data-tip={item.label}>
<NavLink
to={item.path}
end={item.exact}
className={({ isActive }) =>
`flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-150 cursor-pointer ${isActive
? "bg-primary text-primary-content"
: "hover:bg-base-300 text-base-content"
}`
}
>
{item.icon}
</NavLink>
</div>
))}
<div className={`absolute ${!isTracking ? "bottom-13" : "bottom-5"} `}>
<label className="swap swap-rotate p-2.5">
{/* this hidden checkbox controls the state */}
<input type="checkbox" className="theme-controller" onChange={toggleTheme}
checked={theme === "dark"} />
{/* sun icon */}
<svg
className="swap-off h-5 w-5 fill-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
{/* moon icon */}
<svg
className="swap-on h-5 w-5 fill-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</label>
</div>
{!isTracking && (
<button
onClick={() => setShowLogoutModal(true)}
className="flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-150 hover:bg-error/10 text-error hover:text-error mt-auto cursor-pointer tooltip tooltip-right"
data-tip={"Logout"}
>
<LogOut size={18} />
</button>
)}
</aside>
{/* Main Content */}
<main className="flex-1 overflow-y-auto p-4 no-scrollbar bg-base-200">
<Outlet context={trackingState} />
</main>
<LogoutModal
open={showLogoutModal}
onClose={() => setShowLogoutModal(false)}
onConfirm={handleLogout}
/>
</div>
</div>
</>
);
};
export default DashboardLayout;