683 lines
22 KiB
TypeScript
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; |