407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import { Clock, TrendingUp, Play, Square, ProjectorIcon, Pause } from "lucide-react";
|
|
import { useOutletContext } from "react-router-dom";
|
|
import NoData from "./NoData";
|
|
import { useDispatch, useSelector } from "react-redux";
|
|
import { initializeProjectTracking, setProjectsDataSlice, setProjectSummarySlice, setSelectedProjectSlice, updateProjectTracking } from "../store/projectSlice";
|
|
import { setError, setLoading, setProfile } from "../store/profileSlice";
|
|
import { getFormattedRemainingTime, isProjectLimitReached } from '../utils/projectLimits';
|
|
import { parseTimeStringToSeconds } from "../utils/timeParser";
|
|
|
|
// Updated Project interface to match JSON structure
|
|
interface Project {
|
|
projectId: string;
|
|
projectName: string;
|
|
activityStatus: "ACTIVE" | "AWAY";
|
|
totalWorkTime: string;
|
|
totalWorkMs: number;
|
|
totalLogs: number;
|
|
status: string
|
|
}
|
|
|
|
interface ProjectTrackingData {
|
|
elapsedTime: number;
|
|
startTime: Date | null;
|
|
endTime: Date | null;
|
|
isTracking: boolean;
|
|
isPaused: boolean;
|
|
}
|
|
|
|
interface TrackingState {
|
|
selectedProject: Project | null;
|
|
// projectTrackingMap: Map<string, ProjectTrackingData>;
|
|
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
|
|
}
|
|
|
|
// Helper function
|
|
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 `;
|
|
};
|
|
|
|
// Widget Component
|
|
const Widget: React.FC<{
|
|
title: string;
|
|
value: string | number;
|
|
subtitle?: string;
|
|
icon?: React.ReactNode;
|
|
color?:
|
|
| "primary"
|
|
| "secondary"
|
|
| "accent"
|
|
| "success"
|
|
| "warning"
|
|
| "error"
|
|
| "info";
|
|
}> = ({ title, value, subtitle, icon, color = "primary" }) => (
|
|
<div className="card bg-base-100 shadow-sm hover:shadow-lg transition-shadow">
|
|
<div className="card-body">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="font-medium text-base-content/60">{title}</h3>
|
|
<p className="text-sm font-bold mt-2">{value}</p>
|
|
</div>
|
|
<div className={`p-3 bg-${color}/10 rounded-lg`}>
|
|
<div className={`text-${color}`}>{icon}</div>
|
|
</div>
|
|
</div>
|
|
{subtitle && <p className="text-xs text-base-content/60 mt-2">{subtitle}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Dashboard Component
|
|
const Dashboard: React.FC = () => {
|
|
const {
|
|
isTracking,
|
|
isPaused,
|
|
elapsedTime,
|
|
startTime,
|
|
handleStartTracking,
|
|
handlePauseResume,
|
|
handleStopTracking,
|
|
handleProjectSelect,
|
|
loadingStart,
|
|
loadingStop,
|
|
isStopping
|
|
} = useOutletContext<TrackingState>();
|
|
|
|
const projectsData = useSelector((state: any) => state.projects.projectsData);
|
|
const selectedProject = useSelector((state: any) => state.projects.selectedProject);
|
|
const projectSummary = useSelector((state: any) => state.projects.projectSummary);
|
|
const projectTrackingMap = useSelector((state: any) => state.projects.projectTrackingMap);
|
|
|
|
console.log("Line 111", projectsData, selectedProject, projectTrackingMap)
|
|
|
|
const dispatch = useDispatch();
|
|
|
|
const fetchProfile = async () => {
|
|
try {
|
|
dispatch(setLoading(true));
|
|
const res = await window.electron.getProfile();
|
|
console.log("Profile", res)
|
|
if (res.success && res.data) {
|
|
dispatch(setProfile(res.data));
|
|
} else {
|
|
dispatch(setError("Failed to fetch profile"));
|
|
}
|
|
} catch (err: any) {
|
|
dispatch(setError(err.message));
|
|
} finally {
|
|
dispatch(setLoading(false));
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchProjects = async () => {
|
|
try {
|
|
console.log("📡 Fetching projects...Dashboard");
|
|
const res = await window.electron.getProjectList();
|
|
console.log(" Project list response in dashboard:", res);
|
|
|
|
if (res?.success && Array.isArray(res.data)) {
|
|
// Store data redux store
|
|
dispatch(setProjectsDataSlice(res.data));
|
|
|
|
dispatch(
|
|
setProjectSummarySlice({
|
|
totalProjects: res.totalProjects || 0,
|
|
activeProjects: res.activeProjects || 0,
|
|
awayProjects: res.awayProjects || 0,
|
|
totalWorkTime: res.totalWorkTime || "0h 0m 0s",
|
|
totalWorkMs: res.totalWorkMs || 0,
|
|
vaName: res.vaName || "",
|
|
vaId: res.vaId || "",
|
|
})
|
|
);
|
|
|
|
// Auto-select first project
|
|
if (res.data.length > 0 && !selectedProject) {
|
|
const firstProject = res.data[0];
|
|
|
|
// Select the project
|
|
dispatch(setSelectedProjectSlice(firstProject));
|
|
|
|
const initialElapsed = parseTimeStringToSeconds(firstProject.totalWorkTime);
|
|
|
|
// Initialize tracking data for first project
|
|
dispatch(
|
|
initializeProjectTracking({
|
|
projectId: firstProject.projectId,
|
|
initialTime: initialElapsed,
|
|
})
|
|
);
|
|
}
|
|
|
|
fetchProfile()
|
|
|
|
} else {
|
|
console.warn("⚠️ Unexpected response:", res);
|
|
}
|
|
} catch (err) {
|
|
console.error("❌ Error fetching projects:", err);
|
|
}
|
|
};
|
|
|
|
fetchProjects();
|
|
}, []);
|
|
|
|
const selectedData = selectedProject
|
|
? projectTrackingMap[selectedProject.projectId]
|
|
: null;
|
|
|
|
// FIX: Get the correct time to display
|
|
const getDisplayTime = () => {
|
|
if (!selectedProject) return 0;
|
|
|
|
// If tracking is active, use real-time elapsedTime
|
|
if (selectedData?.isTracking) {
|
|
return selectedData.elapsedTime || 0;
|
|
}
|
|
|
|
// If not tracking, use server time from selectedProject
|
|
return parseTimeStringToSeconds(selectedProject.totalWorkTime);
|
|
};
|
|
|
|
const displayTime = getDisplayTime();
|
|
|
|
const formatWorkTime = (time: string) => {
|
|
if (!time) return "0h 0m";
|
|
|
|
// Example input: "0h 2m 29s"
|
|
const parts = time.split(" ");
|
|
|
|
// Always return first two parts: ["0h", "2m"]
|
|
return parts.slice(0, 2).join(" ");
|
|
};
|
|
|
|
const handleProjectSelectLocal = (projectId: string) => {
|
|
const project = projectsData.find((p: any) => p.projectId === projectId);
|
|
if (!project) return;
|
|
|
|
dispatch(setSelectedProjectSlice(project));
|
|
|
|
const existingData = projectTrackingMap[projectId];
|
|
|
|
if (!existingData) {
|
|
const initialElapsed = parseTimeStringToSeconds(project.totalWorkTime);
|
|
dispatch(initializeProjectTracking({
|
|
projectId,
|
|
initialTime: initialElapsed
|
|
}));
|
|
} else {
|
|
// Restore saved tracking state
|
|
dispatch(updateProjectTracking({
|
|
projectId,
|
|
data: existingData
|
|
}));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main className="flex-1 overflow-hidden no-scrollbar max-h-screen">
|
|
{/* Header */}
|
|
<div className="mb-2">
|
|
<h1 className="text-md font-bold">Dashboard</h1>
|
|
</div>
|
|
{/* Tracking Section */}
|
|
<div className="w-full mb-4">
|
|
<div className="card bg-base-100 border border-base-200 shadow-sm card-sm">
|
|
<div className="card-body">
|
|
{isStopping ? (
|
|
// Skeleton loader for tracking section
|
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
|
<div className="text-center sm:text-left w-full">
|
|
<div className="skeleton w-32 h-6 mx-auto sm:mx-0 mb-2"></div>
|
|
<div className="skeleton w-48 h-4 mx-auto sm:mx-0"></div>
|
|
</div>
|
|
<div className="skeleton w-full sm:w-32 h-10 rounded-xl"></div>
|
|
</div>
|
|
) : selectedProject ? (
|
|
<>
|
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
|
<div className="text-center sm:text-left">
|
|
{/* FIX: Use displayTime instead of always using server time */}
|
|
<p className="text-lg font-extrabold text-success">
|
|
{formatTime(displayTime)}
|
|
</p>
|
|
<div>
|
|
<span className="text-base-content/70">Project:</span>{" "}
|
|
<span className="font-semibold">
|
|
{selectedProject.projectName}
|
|
</span>
|
|
</div>
|
|
{/* Show tracking status */}
|
|
{selectedData?.isTracking && (
|
|
<div className="text-xs text-success mt-1">
|
|
● Live Tracking
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!selectedData?.isTracking ? (
|
|
<button
|
|
onClick={() => handleStartTracking(selectedProject)}
|
|
className="btn btn-sm btn-success rounded-xl w-full sm:w-auto"
|
|
disabled={loadingStart}
|
|
>
|
|
{loadingStart ? (
|
|
<span className="loading loading-spinner loading-xs"></span>
|
|
) : (
|
|
<Play className="w-3 h-3" />
|
|
)}
|
|
{loadingStart ? "Starting..." : "Start Tracking"}
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="btn btn-xs btn-error rounded-full"
|
|
onClick={handleStopTracking}
|
|
disabled={loadingStop}
|
|
>
|
|
{loadingStop ? (
|
|
<span className="loading loading-spinner loading-xs"></span>
|
|
) : (
|
|
<Square className="w-3 h-3" />
|
|
)}
|
|
{loadingStop ? "Stopping..." : "Stop Tracking"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="divider my-1" />
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-base-content/60 text-center">
|
|
Select a project below to start tracking.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Projects Table */}
|
|
<div className="border border-base-content/10 bg-base-100 shadow-md min-h-[58vh] flex flex-col">
|
|
{/* 🔹 Sticky Header */}
|
|
<div className="sticky top-0 z-20 bg-base-100 border-b border-base-content/10 p-3 flex justify-between items-center">
|
|
<p className="text-md font-bold">Projects</p>
|
|
</div>
|
|
|
|
{/* 🔹 Table or Empty State or Skeleton */}
|
|
{isStopping ? (
|
|
// Skeleton loader for projects table
|
|
<div className="p-4 space-y-3">
|
|
{[...Array(3)].map((_, index) => (
|
|
<div key={index} className="flex items-center justify-between">
|
|
<div className="skeleton w-8 h-4"></div>
|
|
<div className="skeleton w-32 h-4"></div>
|
|
<div className="skeleton w-16 h-4"></div>
|
|
<div className="skeleton w-20 h-8 rounded"></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : projectsData.length > 0 ? (
|
|
<div
|
|
className="overflow-y-auto no-scrollbar"
|
|
style={{
|
|
maxHeight: "50vh",
|
|
minHeight: "auto",
|
|
}}
|
|
>
|
|
<table className="table table-zebra w-full text-[12px]">
|
|
<thead className="sticky top-0 bg-base-200 z-10">
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Name</th>
|
|
<th>Logs Hours</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{projectsData.map((project: any, index: any) => {
|
|
const isCurrentlySelected =
|
|
selectedProject?.projectId === project.projectId;
|
|
const isDisabled = isTracking && !isCurrentlySelected;
|
|
const limitReached = isProjectLimitReached(project);
|
|
const remainingTime = getFormattedRemainingTime(project);
|
|
|
|
return (
|
|
<tr key={project.projectId} className={`${project.status === 'INACTIVE' ? 'hidden' : ""} `}>
|
|
<td className="text-xs opacity-70">{index + 1}</td>
|
|
<td className="font-medium">
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{project.projectName}</span>
|
|
{limitReached && (
|
|
<span className="text-xs text-error">Daily limit reached</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="font-mono text-xs">
|
|
<div className="flex flex-col">
|
|
<span className="font-mono text-xs">
|
|
{formatWorkTime(project.totalWorkTime)}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="flex flex-col gap-1">
|
|
<button
|
|
className={`btn btn-xs ${isCurrentlySelected ? "btn-success" : "btn-primary"
|
|
}`}
|
|
onClick={() => handleProjectSelectLocal(project.projectId)}
|
|
disabled={isDisabled || limitReached}
|
|
>
|
|
{isCurrentlySelected ? "Selected" : "Select"}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center flex-1 text-sm text-base-content/60">
|
|
<NoData />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default Dashboard; |