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

683 lines
22 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { DateTime } from 'luxon';
import ReactQuill from 'react-quill-new';
import { quillModules } from '../../../utils/quillConfig';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
import { useNavigate } from 'react-router-dom';
import { useToast } from '../../../helper/ToastContext';
interface Project {
projectId: string;
projectName: string;
status?: string | boolean;
}
interface ProjectSummaryResponse {
totalWorkedHours: string;
totalWorkedHoursDetailed: string;
totalWorkedHoursDecimal: string;
totalWorkedMinutes: string;
dedicatedHours: number;
pendingHours: string;
firstLoginTime: string;
lastLogoutTime: string;
}
interface FormErrors {
selectedProjectId?: string;
selectedDate?: string;
startTime?: string;
endTime?: string;
comment?: string;
general?: string;
}
interface AdjustmentTabProps {
projects: Project[];
}
const AdjustmentTab: React.FC<AdjustmentTabProps> = ({ projects }) => {
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);
const [formErrors, setFormErrors] = useState<FormErrors>({});
const [projectSummary, setProjectSummary] = useState<ProjectSummaryResponse | null>(null);
const [startTime, setStartTime] = useState("");
const [endTime, setEndTime] = useState("");
const [comment, setComment] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const quillRef = useRef<any>(null);
const navigate = useNavigate()
const { showToast } = useToast();
// 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;
case 'comment':
if (!value.trim()) {
error = 'Description is required';
}
break;
}
setFormErrors(prev => ({
...prev,
[field]: error
}));
return !error;
};
// Comprehensive form validation
const validateForm = (): boolean => {
const errors: FormErrors = {};
errors.selectedProjectId = validateProjectId(selectedProjectId);
errors.selectedDate = validateDate(selectedDate);
errors.startTime = validateTime(startTime, 'Start time');
errors.endTime = validateTime(endTime, 'End time');
if (!comment.trim()) {
errors.comment = 'Description is required';
}
if (!errors.startTime && !errors.endTime) {
errors.startTime = validateTimeRange(startTime, endTime);
if (!errors.startTime) {
errors.startTime = validateDedicatedHours(startTime, endTime);
}
}
const filteredErrors = Object.fromEntries(
Object.entries(errors).filter(([_, value]) => value !== undefined)
) as FormErrors;
setFormErrors(filteredErrors);
return Object.keys(filteredErrors).length === 0;
};
const convertToLocalTime = (utcTimeString: string): string => {
if (!utcTimeString) return '';
try {
const localTime = DateTime.fromISO(utcTimeString, { zone: 'utc' }).toLocal();
return localTime.toFormat('HH:mm');
} catch (error) {
console.error('Error converting time:', error);
return '';
}
};
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);
if (res?.data?.summary?.firstLoginTime) {
const localStartTime = convertToLocalTime(res?.data?.summary?.firstLoginTime);
setStartTime(localStartTime);
}
if (res?.data?.summary?.lastLogoutTime) {
const localEndTime = convertToLocalTime(res?.data?.summary?.lastLogoutTime);
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(() => {
const editor = quillRef.current?.getEditor();
if (!editor) return;
// Override image handler in toolbar
editor.getModule("toolbar").handlers.image = () => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", "image/*");
input.click();
input.onchange = async () => {
const file = input.files?.[0];
if (file) {
const base64 = await convertToBase64(file);
const range = editor.getSelection();
if (range) {
editor.insertEmbed(range.index, "image", base64);
}
}
};
};
// COMPLETELY disable Quill's default paste behavior
const disableQuillPaste = () => {
// Remove all existing paste event listeners from Quill
const pasteListeners = (editor.root as any).__pasteHandlers || [];
pasteListeners.forEach((handler: any) => {
editor.root.removeEventListener('paste', handler);
});
// Override the clipboard module to do nothing
const clipboard = editor.getModule('clipboard');
const originalPaste = clipboard.paste;
clipboard.paste = (e: any) => {
// Completely prevent Quill's default paste behavior
e.preventDefault();
e.stopPropagation();
return false;
};
};
disableQuillPaste();
// Our custom paste handler - this is the ONLY handler that will run
const handlePaste = async (e: ClipboardEvent) => {
e.preventDefault();
e.stopPropagation();
const items = e.clipboardData?.items;
if (!items || items.length === 0) return;
// Check for images first
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
try {
const base64 = await convertToBase64(file);
const range = editor.getSelection(true);
if (range !== null) {
// Insert the image
editor.insertEmbed(range.index, "image", base64);
// Move cursor after the image
editor.setSelection(range.index + 1, 0);
}
} catch (error) {
console.error("Error processing pasted image:", error);
}
}
return; // Exit after handling the first image
}
}
// If no images found, handle text
const text = e.clipboardData?.getData('text/plain');
if (text) {
const range = editor.getSelection(true);
if (range !== null) {
editor.insertText(range.index, text);
editor.setSelection(range.index + text.length, 0);
}
}
};
// Add our custom paste handler with high priority
editor.root.addEventListener("paste", handlePaste, true);
return () => {
editor.root.removeEventListener("paste", handlePaste, true);
};
}, []);
const convertToBase64 = (file: File): Promise<string> =>
new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
useEffect(() => {
if (selectedProjectId && selectedDate) {
setProjectSummary(null)
setStartTime("");
setEndTime("");
setComment("");
setFormErrors({});
setTotalAdjustment("0h 0m");
fetchLogs();
} else {
setProjectSummary(null);
setStartTime("");
setEndTime("");
}
}, [selectedProjectId, selectedDate]);
// Auto calculations
useEffect(() => {
if (!projectSummary) return;
if (!startTime || !endTime) return;
if (formErrors.startTime?.includes('time') || formErrors.startTime?.includes('dedicated')) {
setFormErrors(prev => ({
...prev,
startTime: undefined
}));
}
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;
}
const diff = end.diff(start, ["hours", "minutes"]).toObject();
const workedHours = diff.hours || 0;
const workedMinutes = diff.minutes || 0;
const totalWorkedDecimal = workedHours + workedMinutes / 60;
const originalWorkedDecimal = Number(projectSummary.totalWorkedHoursDecimal);
const adjustment = totalWorkedDecimal - originalWorkedDecimal;
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);
const newPending = projectSummary.dedicatedHours - totalWorkedDecimal;
const newPendingRounded = newPending.toFixed(2);
setPendingOverride(newPendingRounded);
}, [startTime, endTime, projectSummary]);
const handleProjectSelect = (projectId: string) => {
console.log("handleProjectSelect", projectId);
setSelectedProjectId(projectId);
validateField('selectedProjectId', projectId);
};
const handleSelectedDate = (value: string) => {
setSelectedDate(value);
validateField('selectedDate', value);
};
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 handleDescription = (value: string) => {
setComment(value);
validateField('comment', value);
};
const resetForm = () => {
setSelectedProjectId("");
setSelectedDate("");
setStartTime("");
setEndTime("");
setComment("");
setFormErrors({});
setTotalAdjustment("0h 0m");
// Properly reset the Quill editor
if (quillRef.current) {
const editor = quillRef.current.getEditor();
editor.setContents([]); // Clear editor contents completely
}
};
const handleSubmit = async () => {
if (isSubmitting) return;
setFormErrors({});
if (!validateForm()) {
return;
}
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 {
const res = await window.electron.createTimesheet(payload);
console.log("API Response:", res);
if (res?.success) {
console.log("Timesheet Created Successfully");
resetForm();
// Show success toast instead of alert
showToast("PTO Request submitted successfully!", "success");
// Navigate after a small delay
setTimeout(() => {
navigate('/dashboard/leave-adjustments');
}, 300);
} 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"
}));
showToast(err.message || "Something went wrong while saving timesheet", "error");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-4xl ">
<div className="space-y-3">
{/* 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>
)}
<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} className={`${item.status ? "" : ""}`}>
{item.projectName}
</option>
))}
</select>
{formErrors.selectedProjectId && (
<label className="label">
<span className="label-text-alt text-error text-xs 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 text-xs 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 text-xs 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 text-xs 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 with ReactQuill */}
<div className="form-control flex flex-col gap-1 col-span-2 mb-12">
<label className="label">
<span className="label-text font-semibold text-sm">Description / Reason</span>
</label>
<ReactQuill
ref={quillRef}
theme="snow"
value={comment}
modules={quillModules}
onChange={handleDescription}
className="h-40"
// placeholder="Please enter the task name or leave notes to help us better understand your request."
/>
</div>
{formErrors.comment && (
<label className="label">
<span className="label-text-alt text-error text-xs font-medium">{formErrors.comment}</span>
</label>
)}
{/* 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 ? 'Submitting...' : 'Submit Request'}
</button>
</div>
</div>
</div>
);
};
export default AdjustmentTab;