pmtool / src /components /time-logs /AddTimeLogDialog.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
25.8 kB
import React, { useState, useEffect } from "react";
import { format, isSameDay, parseISO } from "date-fns";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { TimeLog, timeLogApi, TimeLogCreateRequest } from "@/services/timeLogApi";
import { Task, tasksApi } from "@/services/tasksApi";
import { useAuth } from "@/lib/auth-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { PopoverContent, PopoverTrigger, Popover } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { cn } from "@/lib/utils";
import { CalendarIcon, Check, Clock, Loader2, X, AlertCircle } from "lucide-react";
import { CustomSelect } from "@/components/ui/custom-select";
import { showToast } from "@/components/toast-utils";
import { CustomDateInput } from "@/components/ui/custom-date-input";
import { Dropdown } from "@/components/ui/dropdown";
import SlateRichTextEditor from "@/components/custom/SlateRichTextEditor";
interface AddTimeLogDialogProps {
isOpen: boolean;
onClose: () => void;
taskId?: number; // Optional taskId - when provided, the task dropdown will be pre-selected and disabled
onTimeLogAdded?: () => void; // Optional callback after successful creation
editMode?: boolean; // Whether we're editing an existing time log
timeLogToEdit?: TimeLog | null; // The time log to edit when in edit mode
}
export default function AddTimeLogDialog({
isOpen,
onClose,
taskId,
onTimeLogAdded,
editMode = false,
timeLogToEdit = null
}: AddTimeLogDialogProps) {
const { userData } = useAuth();
const queryClient = useQueryClient();
// Form state
const [workDate, setWorkDate] = useState<Date>(new Date());
const [startTime, setStartTime] = useState("9:30 AM");
const [endTime, setEndTime] = useState("10:00 AM");
const [timeLogHours, setTimeLogHours] = useState("");
const [remainingHours, setRemainingHours] = useState("");
const [timeLogDescription, setTimeLogDescription] = useState("");
const [selectedTask, setSelectedTask] = useState<number | null>(taskId || null);
const [timeLogId, setTimeLogId] = useState<number | null>(null);
// Validation state
const [descriptionError, setDescriptionError] = useState(false);
// Submission prevention state
const [isSubmitting, setIsSubmitting] = useState(false);
const [lastSubmissionTime, setLastSubmissionTime] = useState<number>(0);
// Function to calculate hours between two time strings with AM/PM format
const calculateHoursSpent = (startTime: string, endTime: string): string => {
if (!startTime || !endTime) return "";
// Parse start time
const startParts = startTime.split(' ');
const [startHourMin, startAmPm] = [startParts[0], startParts[1]];
let [startHour, startMinute] = startHourMin.split(':').map(Number);
// Convert to 24-hour format for calculation
if (startAmPm === 'PM' && startHour < 12) startHour += 12;
if (startAmPm === 'AM' && startHour === 12) startHour = 0;
// Parse end time
const endParts = endTime.split(' ');
const [endHourMin, endAmPm] = [endParts[0], endParts[1]];
let [endHour, endMinute] = endHourMin.split(':').map(Number);
// Convert to 24-hour format for calculation
if (endAmPm === 'PM' && endHour < 12) endHour += 12;
if (endAmPm === 'AM' && endHour === 12) endHour = 0;
// Convert to minutes then calculate the difference
const startMinutes = startHour * 60 + startMinute;
const endMinutes = endHour * 60 + endMinute;
// Handle case where end time is on the next day
const diffMinutes = endMinutes >= startMinutes
? endMinutes - startMinutes
: (24 * 60 - startMinutes) + endMinutes;
// Convert minutes to hours with two decimal places
return (diffMinutes / 60).toFixed(2);
};
// Add function to convert AM/PM time format to 24-hour format
const convertTo24HourFormat = (timeStr: string): string => {
if (!timeStr) return "";
const timeParts = timeStr.split(' ');
const [hourMin, ampm] = [timeParts[0], timeParts[1]];
let [hour, minute] = hourMin.split(':').map(Number);
// Convert to 24-hour format
if (ampm === 'PM' && hour < 12) hour += 12;
if (ampm === 'AM' && hour === 12) hour = 0;
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
};
// Reset form when dialog opens or taskId changes, or populate with edit data
useEffect(() => {
if (isOpen) {
if (editMode && timeLogToEdit) {
// Add debug logging
console.log('Editing time log:', timeLogToEdit);
console.log('Original time formats:', {
startTime: timeLogToEdit.startTime,
endTime: timeLogToEdit.endTime,
hasAMPM: timeLogToEdit.startTime.includes(' ')
});
// Populate form with data from the time log being edited
setTimeLogId(timeLogToEdit.id);
setWorkDate(new Date(timeLogToEdit.workDate));
// Fix for start and end time format issues:
// Check if time already has AM/PM format, if not, convert from 24-hour format
if (timeLogToEdit.startTime && !timeLogToEdit.startTime.includes(' ')) {
// Convert from 24-hour format to AM/PM
const convertFrom24Hour = (time24: string): string => {
const [hour, minute] = time24.split(':').map(Number);
const period = hour >= 12 ? 'PM' : 'AM';
const hour12 = hour % 12 || 12; // Convert 0 to 12 for 12 AM
return `${hour12}:${minute.toString().padStart(2, '0')} ${period}`;
};
setStartTime(convertFrom24Hour(timeLogToEdit.startTime));
setEndTime(convertFrom24Hour(timeLogToEdit.endTime));
} else {
setStartTime(timeLogToEdit.startTime);
setEndTime(timeLogToEdit.endTime);
}
setTimeLogHours(timeLogToEdit.hrsSpent.toString());
setRemainingHours(timeLogToEdit.remainingHr.toString());
// Ensure description is properly formatted for the rich text editor
setTimeLogDescription(timeLogToEdit.comments || "");
setSelectedTask(timeLogToEdit.taskId);
setDescriptionError(false);
} else {
// Reset form for new time log - default start time to 9:30 AM
setTimeLogId(null);
setWorkDate(new Date());
setStartTime("9:30 AM");
setEndTime("10:00 AM");
// Calculate hours spent
setTimeLogHours(calculateHoursSpent("9:30 AM", "10:00 AM"));
setTimeLogDescription("");
setSelectedTask(taskId || null);
setDescriptionError(false);
// If taskId is provided, also fetch the task to get the remaining hours
if (taskId) {
tasksApi.getById(taskId).then(task => {
if (task?.remainingHr) {
setRemainingHours(task.remainingHr.toString());
} else {
setRemainingHours("");
}
}).catch(error => {
console.error("Error fetching task details:", error);
setRemainingHours("");
});
} else {
setRemainingHours("");
}
}
}
}, [isOpen, taskId, editMode, timeLogToEdit]);
// Fetch tasks for dropdown
const {
data: tasks = [],
isLoading: isLoadingTasks,
error: tasksError
} = useQuery({
queryKey: ["tasks"],
queryFn: () => tasksApi.getAll(),
});
// Create task dropdown options
const taskOptions = tasks.map(task => ({
label: `${task.title} (ID: ${task.id})`,
value: task.id.toString()
}));
// Create mutation
const createMutation = useMutation({
mutationFn: () => {
// Convert AM/PM time to 24-hour format for API
const startTime24 = convertTo24HourFormat(startTime);
const endTime24 = convertTo24HourFormat(endTime);
return timeLogApi.create({
workDate: format(workDate, "yyyy-MM-dd"),
startTime: startTime24, // Using 24-hour format
endTime: endTime24, // Using 24-hour format
comments: timeLogDescription, // Rich text HTML content
createdBy: userData?.userId ? `${userData.userId}` : "",
updatedBy: userData?.userId ? `${userData.userId}` : "",
taskId: selectedTask || 0,
hrsSpent: parseFloat(timeLogHours) || 0,
remainingHr: parseFloat(remainingHours) || 0,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["timeLogs"] });
if (taskId) {
queryClient.invalidateQueries({ queryKey: ["tasks", taskId] });
}
showToast("success", "Time Log Created", "The time log was created successfully.");
handleClose();
if (onTimeLogAdded) {
onTimeLogAdded();
}
},
onError: (error) => {
showToast("error", "Error", `Failed to create time log: ${error}`);
},
onSettled: () => {
// Reset submitting state regardless of success or error
setIsSubmitting(false);
},
});
// Update mutation
const updateMutation = useMutation({
mutationFn: () => {
if (!timeLogId) throw new Error("No time log ID for update");
// Convert AM/PM time to 24-hour format for API
const startTime24 = convertTo24HourFormat(startTime);
const endTime24 = convertTo24HourFormat(endTime);
return timeLogApi.update(timeLogId, {
id: timeLogId,
workDate: format(workDate, "yyyy-MM-dd"),
startTime: startTime24, // Using 24-hour format
endTime: endTime24, // Using 24-hour format
comments: timeLogDescription, // Rich text HTML content
createdBy: timeLogToEdit?.createdBy || (userData?.userId ? `${userData.userId}` : ""),
updatedBy: userData?.userId ? `${userData.userId}` : "",
taskId: selectedTask || 0,
hrsSpent: parseFloat(timeLogHours) || 0,
remainingHr: parseFloat(remainingHours) || 0,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["timeLogs"] });
if (selectedTask) {
queryClient.invalidateQueries({ queryKey: ["tasks", selectedTask] });
}
showToast("success", "Time Log Updated", "The time log was updated successfully.");
handleClose();
if (onTimeLogAdded) {
onTimeLogAdded();
}
},
onError: (error) => {
showToast("error", "Error", `Failed to update time log: ${error}`);
},
onSettled: () => {
// Reset submitting state regardless of success or error
setIsSubmitting(false);
},
});
// Generate time options in half-hour intervals with AM/PM format
const generateTimeOptions = (): { label: string; value: string }[] => {
const options: { label: string; value: string }[] = [];
for (let hour = 1; hour <= 12; hour++) {
// AM options
options.push({
label: `${hour}:00 AM`,
value: `${hour}:00 AM`
});
options.push({
label: `${hour}:30 AM`,
value: `${hour}:30 AM`
});
}
for (let hour = 1; hour <= 12; hour++) {
// PM options
options.push({
label: `${hour}:00 PM`,
value: `${hour}:00 PM`
});
options.push({
label: `${hour}:30 PM`,
value: `${hour}:30 PM`
});
}
return options;
};
const timeOptions = generateTimeOptions();
// Function to get next time slot
const getNextTimeSlot = (currentTime: string): string => {
const timeIndex = timeOptions.findIndex(option => option.value === currentTime);
if (timeIndex !== -1 && timeIndex < timeOptions.length - 1) {
return timeOptions[timeIndex + 1].value;
}
// If current time is the last option, just return it (this is a fallback)
return currentTime;
};
// Initialize timeLogHours on component mount
useEffect(() => {
if (startTime && endTime && !timeLogHours) {
setTimeLogHours(calculateHoursSpent(startTime, endTime));
}
}, []);
// Handle form submission
const handleSubmit = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const currentTime = Date.now();
// Prevent multiple submissions with debounce (2 seconds)
if (isSubmitting || (currentTime - lastSubmissionTime < 2000)) {
return; // Already submitting or too soon after last submission
}
// Reset validation errors
setDescriptionError(false);
// Validate required fields
if (!selectedTask) {
showToast("error", "Error", "Please select a task");
return;
}
if (!startTime || !endTime) {
showToast("error", "Error", "Please select start and end times");
return;
}
// Check if the description is empty or only contains HTML tags without text
const strippedDescription = timeLogDescription.replace(/<[^>]*>/g, '').trim();
if (!strippedDescription) {
setDescriptionError(true);
showToast("error", "Error", "Description is required");
return;
}
// Set submitting state
setIsSubmitting(true);
setLastSubmissionTime(currentTime);
// If in edit mode, update the time log, otherwise create a new one
if (editMode && timeLogId) {
updateMutation.mutate();
} else {
createMutation.mutate();
}
};
// Function to get yesterday's date
const getYesterday = () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return yesterday;
};
// Handle clean close with reset
const handleClose = () => {
setWorkDate(new Date());
setStartTime("9:30 AM");
setEndTime("10:00 AM");
setTimeLogHours(calculateHoursSpent("9:30 AM", "10:00 AM"));
setRemainingHours("");
setTimeLogDescription("");
setSelectedTask(null);
setTimeLogId(null);
setDescriptionError(false);
setIsSubmitting(false); // Reset submitting state
setLastSubmissionTime(0); // Reset last submission time
onClose();
};
if (!isOpen) return null;
// Find selected task details if available
const selectedTaskDetails = tasks.find(t => t.id === selectedTask);
// Determine if the current operation is in progress
const isPending = isSubmitting || (editMode ? updateMutation.isPending : createMutation.isPending);
// Helper function to ensure content is HTML formatted
const ensureHtmlFormat = (content: string): string => {
if (!content) return '';
// Check if the content already looks like HTML (contains HTML tags)
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content);
// If it's already HTML, return as is
if (hasHtmlTags) {
return content;
}
// Otherwise, wrap it in a paragraph tag
return `<p>${content}</p>`;
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]"
onClick={() => {
// Prevent closing while submitting
if (!isPending) {
handleClose();
}
}}
>
<div
className="bg-background rounded-lg shadow-lg w-full max-w-md p-6 relative"
onClick={(e) => e.stopPropagation()}
style={{ zIndex: 10000 }}
>
{/* Close button in the corner */}
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-2"
disabled={isPending}
onClick={() => {
// Prevent closing while submitting
if (!isPending) {
handleClose();
}
}}
>
<X className="h-4 w-4" />
</Button>
<h3 className="text-lg font-semibold mb-4">
{editMode
? "Edit Time Log"
: selectedTaskDetails
? `Log Time for ${selectedTaskDetails.title}`
: "Add New Time Log"}
</h3>
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-1 block">Work Date</Label>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-2">
<Button
variant={workDate && isSameDay(workDate, getYesterday()) ? "default" : "outline"}
className="w-full justify-center text-xs sm:text-sm"
onClick={() => {
const yesterday = getYesterday();
setWorkDate(yesterday);
}}
type="button"
>
Yesterday
</Button>
<Button
variant={workDate && isSameDay(workDate, new Date()) ? "default" : "outline"}
className="w-full justify-center text-xs sm:text-sm"
onClick={() => {
const today = new Date();
setWorkDate(today);
}}
type="button"
>
Today
</Button>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-center text-left font-normal",
!workDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{workDate ? format(workDate, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 z-[10001]">
<Calendar
mode="single"
selected={workDate}
onSelect={(date) => date && setWorkDate(date)}
initialFocus
disabled={(date) => {
// Only allow today and yesterday in normal mode
// In edit mode, allow the current date from the time log
const yesterday = getYesterday();
if (editMode && timeLogToEdit) {
const timeLogDate = new Date(timeLogToEdit.workDate);
// Allow the original date, today and yesterday
return date > new Date() ||
(date < yesterday && !isSameDay(date, timeLogDate));
}
return date > new Date() || (date < yesterday);
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
{(!taskId || !selectedTaskDetails) && (
<div className="space-y-2" style={{ zIndex: 50, position: 'relative' }}>
<Label className="text-sm font-medium mb-1 block">Task*</Label>
{isLoadingTasks ? (
<div className="flex items-center space-x-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">Loading tasks...</span>
</div>
) : (
<Dropdown
options={taskOptions}
value={selectedTask?.toString() || ""}
onValueChange={(value) => {
const taskId = parseInt(value);
setSelectedTask(taskId);
// Auto-fill remaining hours from the task if available
const task = tasks.find(t => t.id === taskId);
if (task?.remainingHr) {
setRemainingHours(task.remainingHr.toString());
}
}}
placeholder="Select a task"
className="w-full"
disabled={!!taskId || (editMode && !!timeLogToEdit)}
/>
)}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2" style={{ zIndex: 45, position: 'relative' }}>
<Label className="text-sm font-medium mb-1 block">Start Time*</Label>
<Dropdown
options={timeOptions}
value={startTime}
onValueChange={(value) => {
setStartTime(value);
// Automatically set end time to next time slot
const nextSlot = getNextTimeSlot(value);
setEndTime(nextSlot);
// Calculate hours spent between start and end
const hoursSpent = calculateHoursSpent(value, nextSlot);
setTimeLogHours(hoursSpent);
}}
placeholder="Select start time"
className="w-full"
/>
</div>
<div className="space-y-2" style={{ zIndex: 40, position: 'relative' }}>
<Label className="text-sm font-medium mb-1 block">End Time*</Label>
<Dropdown
options={timeOptions}
value={endTime}
onValueChange={(value) => {
setEndTime(value);
if (startTime) {
// Calculate hours spent between start and end
const hoursSpent = calculateHoursSpent(startTime, value);
setTimeLogHours(hoursSpent);
}
}}
placeholder="Select end time"
className="w-full"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium mb-1 block">Hours Spent*</Label>
<Input
type="number"
min="0"
step="0.5"
value={timeLogHours}
onChange={(e) => setTimeLogHours(e.target.value)}
placeholder="Hours worked"
className="bg-gray-50"
readOnly
/>
<p className="text-xs text-muted-foreground">Auto-calculated from time range</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium mb-1 block">Remaining Hours*</Label>
<Input
type="number"
min="0"
step="0.5"
value={remainingHours}
onChange={(e) => setRemainingHours(e.target.value)}
placeholder="Hours remaining"
/>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium mb-1 block flex items-center">
Description* <span className="text-rose-500 ml-0.5">*</span>
</Label>
{descriptionError && (
<div className="text-xs font-medium text-rose-500 flex items-center">
<AlertCircle className="h-3 w-3 mr-1" />
Required field
</div>
)}
</div>
<SlateRichTextEditor
key={timeLogId || 'new-time-log'} // Add key to force re-render when time log changes
initialValue={ensureHtmlFormat(timeLogDescription)}
onChange={(value) => {
setTimeLogDescription(value);
if (value.trim()) {
setDescriptionError(false);
}
}}
placeholder="What did you work on? Please provide detailed description of your work."
minHeight="150px"
className={cn(
descriptionError && "border-rose-500 focus-visible:ring-rose-500"
)}
/>
<p className="text-xs text-muted-foreground">
Provide a detailed description of the work completed during this time period.
</p>
</div>
<Button
className="w-full py-2 px-4 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleSubmit}
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{editMode ? "Updating..." : "Saving..."}
</>
) : editMode ? (
<>
<Check className="h-4 w-4 mr-2" /> Update Time Log
</>
) : (
<>
<Clock className="h-4 w-4 mr-2" /> Log Time
</>
)}
</Button>
</div>
</div>
</div>
);
}