va-tracker/src/ui/pages/LeaveAndAdjustments/LeaveAndAdjustments.tsx

801 lines
28 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import AdjustmentModal from './AdjustmentModal';
import { RootState } from '../../store';
import { useSelector } from 'react-redux';
import { DateTime } from 'luxon';
interface LeaveFormData {
fromDate: string;
toDate: string;
}
interface AdjustmentEntry {
id: number;
date: string;
loggedHours: number;
}
interface LogEntry {
id: number;
date: string;
hours: number;
task: string;
status: "Completed" | "In Progress" | "Pending";
}
interface ProjectSummaryResponse {
totalWorkedHours: string;
totalWorkedHoursDetailed: string;
totalWorkedHoursDecimal: string;
totalWorkedMinutes: string;
dedicatedHours: number;
pendingHours: string;
firstLoginTime: string;
lastLogoutTime: string;
}
const data: LogEntry[] = [
{ id: 1, date: "2025-11-01", hours: 8, task: "Frontend Development", status: "Completed" },
{ id: 2, date: "2025-11-02", hours: 6, task: "API Integration", status: "In Progress" },
{ id: 3, date: "2025-11-03", hours: 7.5, task: "UI Testing", status: "Pending" },
{ id: 4, date: "2025-11-04", hours: 9, task: "Bug Fixing", status: "Completed" },
{ id: 5, date: "2025-11-05", hours: 5.5, task: "Code Review", status: "Completed" },
];
type TabType = 'create_and_adjust_timesheet' | 'sick' | 'adjustment';
// Validation interface
interface FormErrors {
selectedProjectId?: string;
selectedDate?: string;
startTime?: string;
endTime?: string;
general?: string;
}
const LeaveAndAdjustments: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('create_and_adjust_timesheet');
const projects = useSelector((state: RootState) => state.projects.projectsData);
console.log("LeaveAndAdjustments", projects)
// PTO State
const [ptoLeave, setPtoLeave] = useState({
fromDate: '',
toDate: '',
});
const [ptoErrors, setPtoErrors] = useState<Partial<LeaveFormData>>({});
// Sick Leave State
const [sickLeave, setSickLeave] = useState<LeaveFormData>({
fromDate: '',
toDate: '',
});
const [sickErrors, setSickErrors] = useState<Partial<LeaveFormData>>({});
const [selectedProjectId, setSelectedProjectId] = useState("")
const [selectedDate, setSelectedDate] = useState("")
const [totalAdjustment, setTotalAdjustment] = useState("0h 0m");
const [pendingOverride, setPendingOverride] = useState<string | null>(null);
const [loadingSummary, setLoadingSummary] = useState(false);
// Form Validation State
const [formErrors, setFormErrors] = useState<FormErrors>({});
// Project Summary State
const [projectSummary, setProjectSummary] = useState<ProjectSummaryResponse | null>(null);
const [startTime, setStartTime] = useState("");
const [endTime, setEndTime] = useState("");
// Adjustment State
const [selectedPeriod, setSelectedPeriod] = useState<string>('week');
const [adjustmentData, setAdjustmentData] = useState<AdjustmentEntry[]>([
{ id: 1, date: '2025-11-01', loggedHours: 8 },
{ id: 2, date: '2025-11-02', loggedHours: 7.5 },
{ id: 3, date: '2025-11-03', loggedHours: 8 },
]);
const [editingId, setEditingId] = useState<number | null>(null);
const [editHours, setEditHours] = useState<string>('');
// Validation function
const validateLeaveForm = (data: LeaveFormData): Partial<LeaveFormData> => {
const errors: Partial<LeaveFormData> = {};
if (!data.fromDate) {
errors.fromDate = 'From date is required';
}
if (!data.toDate) {
errors.toDate = 'To date is required';
}
if (data.fromDate && data.toDate && new Date(data.fromDate) > new Date(data.toDate)) {
errors.toDate = 'To date must be after from date';
}
return errors;
};
// Function to convert UTC time to local time in HH:mm format
const convertToLocalTime = (utcTimeString: string): string => {
if (!utcTimeString) return '';
try {
// Parse the UTC time and convert to local timezone
const localTime = DateTime.fromISO(utcTimeString, { zone: 'utc' }).toLocal();
// Format to HH:mm (24-hour format)
return localTime.toFormat('HH:mm');
} catch (error) {
console.error('Error converting time:', error);
return '';
}
};
// Field validation functions
const validateProjectId = (projectId: string): string | undefined => {
if (!projectId) return 'Project selection is required';
return undefined;
};
const validateDate = (date: string): string | undefined => {
if (!date) return 'Date is required';
const selectedDateObj = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDateObj > today) return 'Cannot select future dates';
return undefined;
};
const validateTime = (time: string, fieldName: string): string | undefined => {
if (!time) return `${fieldName} is required`;
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(time)) return 'Invalid time format';
return undefined;
};
const validateTimeRange = (start: string, end: string): string | undefined => {
if (!start || !end) return undefined;
const startTime = DateTime.fromFormat(start, "HH:mm");
const endTime = DateTime.fromFormat(end, "HH:mm");
if (!startTime.isValid || !endTime.isValid) return 'Invalid time values';
if (endTime <= startTime) return 'End time must be after start time';
return undefined;
};
const validateDedicatedHours = (start: string, end: string): string | undefined => {
if (!start || !end || !projectSummary) return undefined;
const startTime = DateTime.fromFormat(start, "HH:mm");
const endTime = DateTime.fromFormat(end, "HH:mm");
if (!startTime.isValid || !endTime.isValid) return undefined;
const diff = endTime.diff(startTime, ["hours", "minutes"]).toObject();
const workedHours = (diff.hours || 0) + (diff.minutes || 0) / 60;
if (workedHours > projectSummary.dedicatedHours) {
return `Cannot exceed project's dedicated hours (${projectSummary.dedicatedHours} hrs)`;
}
return undefined;
};
// Real-time field validation
const validateField = (field: keyof FormErrors, value: string) => {
let error: string | undefined;
switch (field) {
case 'selectedProjectId':
error = validateProjectId(value);
break;
case 'selectedDate':
error = validateDate(value);
break;
case 'startTime':
error = validateTime(value, 'Start time');
if (!error && endTime) {
error = validateTimeRange(value, endTime) || validateDedicatedHours(value, endTime);
}
break;
case 'endTime':
error = validateTime(value, 'End time');
if (!error && startTime) {
error = validateTimeRange(startTime, value) || validateDedicatedHours(startTime, value);
}
break;
}
setFormErrors(prev => ({
...prev,
[field]: error
}));
return !error; // Return true if valid
};
// Comprehensive form validation
const validateForm = (): boolean => {
const errors: FormErrors = {};
// Validate individual fields
errors.selectedProjectId = validateProjectId(selectedProjectId);
errors.selectedDate = validateDate(selectedDate);
errors.startTime = validateTime(startTime, 'Start time');
errors.endTime = validateTime(endTime, 'End time');
// Validate time range and dedicated hours
if (!errors.startTime && !errors.endTime) {
errors.startTime = validateTimeRange(startTime, endTime);
if (!errors.startTime) {
errors.startTime = validateDedicatedHours(startTime, endTime);
}
}
// Filter out undefined errors
const filteredErrors = Object.fromEntries(
Object.entries(errors).filter(([_, value]) => value !== undefined)
) as FormErrors;
setFormErrors(filteredErrors);
return Object.keys(filteredErrors).length === 0;
};
const handlePtoSubmit = async () => {
const errors = validateLeaveForm(ptoLeave);
if (Object.keys(errors).length > 0) {
setPtoErrors(errors);
return;
}
try {
console.log('Submitting PTO:', ptoLeave);
alert('PTO request submitted successfully!');
setPtoLeave({ fromDate: '', toDate: '' });
setPtoErrors({});
} catch (error) {
alert('Failed to submit request. Please try again.');
console.error('Error:', error);
}
};
// Sick Leave Handlers
const handleSickChange = (field: keyof LeaveFormData, value: string) => {
setSickLeave(prev => ({ ...prev, [field]: value }));
if (sickErrors[field]) {
setSickErrors(prev => ({ ...prev, [field]: undefined }));
}
};
const handleSickSubmit = async () => {
const errors = validateLeaveForm(sickLeave);
if (Object.keys(errors).length > 0) {
setSickErrors(errors);
return;
}
try {
console.log('Submitting Sick Leave:', sickLeave);
alert('Sick Leave request submitted successfully!');
setSickLeave({ fromDate: '', toDate: '' });
setSickErrors({});
} catch (error) {
alert('Failed to submit request. Please try again.');
console.error('Error:', error);
}
};
const [editingEntry, setEditingEntry] = useState<AdjustmentEntry | null>(null);
const [newHours, setNewHours] = useState<number>(0);
const [comment, setComment] = useState<string>("")
const [isSubmitting, setIsSubmitting] = useState(false);
const handlePeriodChange = (value: string) => setSelectedPeriod(value);
const handleEditClick = (entry: AdjustmentEntry) => {
setEditingEntry(entry);
const modal = document.getElementById("editModal") as HTMLDialogElement;
modal?.showModal();
};
const handleCloseModal = () => {
setEditingEntry(null);
setNewHours(0)
const modal = document.getElementById("editModal") as HTMLDialogElement;
modal?.close();
};
const handleSave = () => {
console.log("Saving:", { ...editingEntry, updatedHours: newHours });
handleCloseModal();
};
const handleProjectSelect = (projectId: string) => {
console.log("handleProjectSelect", projectId)
setSelectedProjectId(projectId);
validateField('selectedProjectId', projectId);
}
const handleSelectedDate = (value: string) => {
setSelectedDate(value);
validateField('selectedDate', value);
};
const debugTimezoneInfo = (utcTimeString: string, check: string) => {
const utcTime = DateTime.fromISO(utcTimeString, { zone: 'utc' });
const localTime = utcTime.toLocal();
console.log(`-------------------------Timezone Debug Info: ${check}----------------------`);
console.log('UTC Time:', utcTime.toString());
console.log('Local Time:', localTime.toString());
console.log('System Timezone:', localTime.zoneName);
console.log('Formatted Local Time:', localTime.toFormat('HH:mm'));
};
const fetchLogs = async () => {
try {
console.log("Sending =>", selectedProjectId, selectedDate);
setLoadingSummary(true);
const res = await window.electron.getProjectSummary(
selectedProjectId,
selectedDate
);
console.log("📥 Summary Response:", res);
if (res.success) {
setProjectSummary(res?.data?.summary);
// Convert UTC times to local timezone and set them
if (res?.data?.summary?.firstLoginTime) {
const localStartTime = convertToLocalTime(res?.data?.summary?.firstLoginTime);
debugTimezoneInfo(localStartTime, "start");
setStartTime(localStartTime);
}
if (res?.data?.summary?.lastLogoutTime) {
const localEndTime = convertToLocalTime(res?.data?.summary?.lastLogoutTime);
debugTimezoneInfo(localEndTime, "end");
setEndTime(localEndTime);
}
} else {
console.warn("⚠️ Summary API failed:", res.message);
setProjectSummary(null);
setStartTime("");
setEndTime("");
}
} catch (err) {
console.error("❌ Error fetching project summary:", err);
setProjectSummary(null);
setStartTime("");
setEndTime("");
} finally {
setLoadingSummary(false);
}
};
useEffect(() => {
if (selectedProjectId && selectedDate) {
fetchLogs();
} else {
setProjectSummary(null);
setStartTime("");
setEndTime("");
}
}, [selectedProjectId, selectedDate]);
// Auto calculations --- derived state
useEffect(() => {
if (!projectSummary) return; // No API summary yet
if (!startTime || !endTime) return;
// Clear previous time range errors when times change
if (formErrors.startTime?.includes('time') || formErrors.startTime?.includes('dedicated')) {
setFormErrors(prev => ({
...prev,
startTime: undefined
}));
}
// 1. Validation: prevent end < start
const start = DateTime.fromFormat(startTime, "HH:mm");
const end = DateTime.fromFormat(endTime, "HH:mm");
if (!start.isValid || !end.isValid) return;
if (end < start) {
setTotalAdjustment("0h 0m");
return;
}
// 2. Calculate worked minutes from UI input
const diff = end.diff(start, ["hours", "minutes"]).toObject();
const workedHours = diff.hours || 0;
const workedMinutes = diff.minutes || 0;
const totalWorkedDecimal = workedHours + workedMinutes / 60;
// 3. Compare with server original total
const originalWorkedDecimal = Number(projectSummary.totalWorkedHoursDecimal);
const adjustment = totalWorkedDecimal - originalWorkedDecimal;
// 4. Build readable format (always positive for display)
const adjH = Math.floor(Math.abs(adjustment));
const adjM = Math.round((Math.abs(adjustment) % 1) * 60);
const formatted = `${adjustment >= 0 ? "+" : "-"}${adjH}h ${adjM}m`;
setTotalAdjustment(formatted);
// 5. Update Pending Hours -> dedicated - new worked
const newPending = projectSummary.dedicatedHours - totalWorkedDecimal;
const newPendingRounded = newPending.toFixed(2);
setPendingOverride(newPendingRounded);
}, [startTime, endTime, projectSummary]);
const handleStartTimeChange = (value: string) => {
console.log("Start changed:", value);
setStartTime(value);
validateField('startTime', value);
};
const handleEndTimeChange = (value: string) => {
console.log("End changed:", value);
setEndTime(value);
validateField('endTime', value);
};
const handleSubmit = async () => {
if (isSubmitting) return;
// Clear previous errors
setFormErrors({});
// Validate all fields
if (!validateForm()) {
return;
}
// Final dedicated hours check
const dedicatedHoursError = validateDedicatedHours(startTime, endTime);
if (dedicatedHoursError) {
setFormErrors(prev => ({
...prev,
startTime: dedicatedHoursError
}));
return;
}
const payload = {
projectId: selectedProjectId,
date: selectedDate,
loginTime: `${startTime}:00`,
logoutTime: `${endTime}:00`,
comment
};
console.log("Submitting payload:", payload);
setIsSubmitting(true);
try {
// Add await here to properly wait for the response
const res = await window.electron.createTimesheet(payload);
console.log("API Response:", res);
if (res?.success) {
console.log("Timesheet Created Successfully");
// Show success message
alert(res.message);
// Reset form or navigate away
setSelectedProjectId("");
setSelectedDate("");
setStartTime("");
setEndTime("");
setComment("");
setFormErrors({});
setTotalAdjustment("0h 0m");
} else {
setFormErrors(prev => ({
...prev,
general: res?.message || "Failed to create timesheet"
}));
}
} catch (err: any) {
console.error("Submission error:", err);
setFormErrors(prev => ({
...prev,
general: err.message || "Something went wrong while saving timesheet"
}));
} finally {
setIsSubmitting(false);
}
};
const handleDescription = (value: string) => {
setComment(value);
};
return (
<div className="max-h-screen">
<div className="w-full mx-auto">
{/* Header */}
<div className=" ">
<h1 className="text-mb font-bold mb-2">Leave & Adjust Timesheet</h1>
</div>
{/* General Error Alert */}
{formErrors.general && (
<div className="alert alert-error shadow-sm my-1 px-6 py-1 text-xs text-base-100">
<span>{formErrors.general}</span>
</div>
)}
{/* Main Container */}
<div className="rounded-lg bg-base-100 overflow-hidden">
{/* Tabs Navigation */}
<div className="flex ">
<button
onClick={() => setActiveTab('create_and_adjust_timesheet')}
className={`flex-1 px-6 py-3 text-xs font-semibold transition-all duration-300 relative ${activeTab === 'create_and_adjust_timesheet'
? ' text-primary border-b-2 border-primary'
: ''
}`}
>
<span className="flex items-center justify-center gap-2 cursor-pointer">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Create or Adjust Timesheet
</span>
</button>
</div>
{/* Tab Content */}
<div className="px-8 py-4 overflow-y-auto no-scrollbar max-h-[81vh]">
{/* PTO Tab */}
{activeTab === 'create_and_adjust_timesheet' && (
<div className="animate-fade-in">
<div className="max-w-4xl ">
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
{/* Project Selection */}
<div className="form-control col-span-2">
<label className="label">
<span className="label-text font-semibold text-sm">Select project</span>
</label>
<select
className={`select select-sm ${formErrors.selectedProjectId ? 'select-error' : ''}`}
value={selectedProjectId}
onChange={(e) => handleProjectSelect(e.target.value)}
>
<option value="" disabled>
Pick a project
</option>
{projects.map((item, index) => (
<option key={item.projectId || index} value={item.projectId}>
{item.projectName}
</option>
))}
</select>
{formErrors.selectedProjectId && (
<label className="label">
<span className="label-text-alt text-error font-medium">{formErrors.selectedProjectId}</span>
</label>
)}
</div>
{/* Date Selection */}
<div className="form-control col-span-2">
<label className="label">
<span className="label-text font-semibold text-sm">Select timesheet date</span>
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => handleSelectedDate(e.target.value)}
className={`input input-bordered input-sm w-full focus:input-primary transition-all ${formErrors.selectedDate ? 'input-error' : ''}`}
/>
{formErrors.selectedDate && (
<label className="label">
<span className="label-text-alt text-error font-medium">{formErrors.selectedDate}</span>
</label>
)}
</div>
{/* Current Total Time */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold text-sm ">Current total time</span>
</label>
{loadingSummary ? (
<div className="skeleton h-9 w-full"></div>
) :
<input
type="text"
value={
projectSummary
? `${pendingOverride ? (projectSummary.dedicatedHours - Number(pendingOverride)).toFixed(2) : projectSummary.totalWorkedHoursDecimal} hrs`
: "0 hrs"
}
readOnly
className="input input-bordered input-sm w-full bg-base-200"
/>
}
</div>
{/* Current Pending Time */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold text-sm">Current pending time</span>
</label>
{loadingSummary ? (
<div className="skeleton h-9 w-full"></div>
) :
<input
type="text"
value={
projectSummary
? `${pendingOverride ?? projectSummary.pendingHours} hrs`
: "0 hrs"
}
readOnly
className="input input-bordered input-sm w-full bg-base-200"
/>
}
</div>
{/* Start Time */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold text-sm">Start time</span>
</label>
{loadingSummary ? (
<div className="skeleton h-9 w-full"></div>
) :
<input
type="time"
value={startTime}
onChange={(e) => handleStartTimeChange(e.target.value)}
className={`input input-bordered input-sm w-full focus:input-primary transition-all ${formErrors.startTime ? 'input-error' : ''}`}
/>
}
{formErrors.startTime && (
<label className="label">
<span className="label-text-alt text-error font-medium">{formErrors.startTime}</span>
</label>
)}
</div>
{/* End Time */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold text-sm ">End time</span>
</label>
{loadingSummary ? (
<div className="skeleton h-9 w-full"></div>
) :
<input
type="time"
value={endTime}
onChange={(e) => handleEndTimeChange(e.target.value)}
className={`input input-bordered input-sm w-full focus:input-primary transition-all ${formErrors.endTime ? 'input-error' : ''}`}
/>
}
{formErrors.endTime && (
<label className="label">
<span className="label-text-alt text-error font-medium">{formErrors.endTime}</span>
</label>
)}
</div>
{/* Total Time Adjustment */}
<div className="form-control col-span-2">
<label className="label">
<span className="label-text font-semibold text-sm">Total time adjustment</span>
</label>
<input
type="text"
placeholder="Calculated automatically"
value={totalAdjustment}
readOnly
className="input input-bordered input-sm w-full bg-base-200"
/>
</div>
</div>
{/* Description */}
<div className="form-control flex flex-col gap-1">
<label className="label">
<span className="label-text font-semibold text-sm ">Description / Reason</span>
</label>
<textarea
className="textarea text-xs textarea-bordered h-40 w-full focus:textarea-primary transition-all"
placeholder="Please enter the task name or leave notes to help us better understand your request."
value={comment}
onChange={(e) => handleDescription(e.target.value)}
/>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<button
onClick={handleSubmit}
disabled={isSubmitting}
className={`btn btn-primary flex-1 hover:shadow-lg transition-all duration-300 ${isSubmitting ? 'loading' : ''}`}
>
{!isSubmitting && (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{isSubmitting ? 'Submitting...' : 'Submit Request'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Other tabs remain the same */}
{activeTab === 'sick' && (
<div className="animate-fade-in">
{/* Sick leave content remains the same */}
</div>
)}
{activeTab === 'adjustment' && (
<div className="animate-fade-in">
{/* Adjustment content remains the same */}
</div>
)}
</div>
</div>
</div>
<style>{`
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
`}</style>
<AdjustmentModal
editingEntry={editingEntry}
newHours={newHours}
setNewHours={setNewHours}
handleCloseModal={handleCloseModal}
handleSave={handleSave}
/>
</div>
);
};
export default LeaveAndAdjustments;