Enable editing task details and tracking time within project views

Adds a modal for viewing and editing task notes and time entries, integrates task-level clicks for detail access, and introduces time tracking functionality.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/P2PZNJ9
This commit is contained in:
paul-nothaft
2025-09-11 22:03:27 +00:00
parent d08429e74b
commit 8b5727afb0
3 changed files with 642 additions and 5 deletions
+541
View File
@@ -0,0 +1,541 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar } from 'lucide-react';
interface ProjectTask {
id: string;
title: string;
description?: string;
priority: 'low' | 'medium' | 'high';
status: 'todo' | 'inProgress' | 'done';
estimatedHours?: number;
labelId?: string;
notes?: string;
timeTracked?: number; // in minutes
timeEntries?: TimeEntry[];
}
interface TimeEntry {
id: string;
date: Date;
timeSpent: number; // in minutes
description?: string;
}
interface TaskDetailsModalProps {
isOpen: boolean;
onClose: () => void;
task: ProjectTask | null;
onSave: (updatedTask: ProjectTask) => void;
labels?: Array<{ id: string; name: string; color: string }>;
}
export default function TaskDetailsModal({
isOpen,
onClose,
task,
onSave,
labels = []
}: TaskDetailsModalProps) {
// Notes state
const [notes, setNotes] = useState('');
const [isEditingNotes, setIsEditingNotes] = useState(false);
// Time reporting state
const [isAddingTime, setIsAddingTime] = useState(false);
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const [manualHours, setManualHours] = useState('');
const [timeDescription, setTimeDescription] = useState('');
const [activeTimeTab, setActiveTimeTab] = useState('clock');
// Time entries state
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
useEffect(() => {
if (task && isOpen) {
setNotes(task.notes || '');
setTimeEntries(task.timeEntries || []);
// Reset time input state
setHours(0);
setMinutes(0);
setManualHours('');
setTimeDescription('');
setIsAddingTime(false);
setIsEditingNotes(false);
}
}, [task, isOpen]);
const handleClose = () => {
setNotes('');
setTimeEntries([]);
setIsEditingNotes(false);
setIsAddingTime(false);
setHours(0);
setMinutes(0);
setManualHours('');
setTimeDescription('');
onClose();
};
const handleSaveNotes = () => {
if (!task) return;
const updatedTask = { ...task, notes };
onSave(updatedTask);
setIsEditingNotes(false);
};
const handleSaveTimeEntry = () => {
if (!task) return;
let totalMinutes = 0;
if (activeTimeTab === 'clock') {
totalMinutes = hours * 60 + minutes;
} else {
const parsedHours = parseFloat(manualHours) || 0;
totalMinutes = Math.round(parsedHours * 60);
}
if (totalMinutes <= 0) return;
const newTimeEntry: TimeEntry = {
id: Date.now().toString(),
date: new Date(),
timeSpent: totalMinutes,
description: timeDescription.trim() || undefined
};
const updatedTimeEntries = [...timeEntries, newTimeEntry];
const updatedTimeTracked = (task.timeTracked || 0) + totalMinutes;
const updatedTask = {
...task,
timeEntries: updatedTimeEntries,
timeTracked: updatedTimeTracked
};
setTimeEntries(updatedTimeEntries);
onSave(updatedTask);
// Reset time input
setHours(0);
setMinutes(0);
setManualHours('');
setTimeDescription('');
setIsAddingTime(false);
console.log(`Time entry saved: ${totalMinutes} minutes for task: ${task.title}`);
};
const handleDeleteTimeEntry = (entryId: string) => {
if (!task) return;
const entryToDelete = timeEntries.find(entry => entry.id === entryId);
if (!entryToDelete) return;
const updatedTimeEntries = timeEntries.filter(entry => entry.id !== entryId);
const updatedTimeTracked = Math.max(0, (task.timeTracked || 0) - entryToDelete.timeSpent);
const updatedTask = {
...task,
timeEntries: updatedTimeEntries,
timeTracked: updatedTimeTracked
};
setTimeEntries(updatedTimeEntries);
onSave(updatedTask);
};
const formatTime = (h: number, m: number) => {
return `${h}h ${m}m`;
};
const formatMinutesToHours = (minutes: number) => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return formatTime(h, m);
};
const getCurrentTimeDisplay = () => {
if (activeTimeTab === 'clock') {
return formatTime(hours, minutes);
} else {
const parsedHours = parseFloat(manualHours) || 0;
const h = Math.floor(parsedHours);
const m = Math.round((parsedHours - h) * 60);
return formatTime(h, m);
}
};
const getTotalTrackedTime = () => {
return timeEntries.reduce((total, entry) => total + entry.timeSpent, 0);
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'text-red-600 bg-red-50 border-red-200';
case 'medium': return 'text-yellow-600 bg-yellow-50 border-yellow-200';
case 'low': return 'text-green-600 bg-green-50 border-green-200';
default: return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'done': return 'text-green-600 bg-green-50 border-green-200';
case 'inProgress': return 'text-blue-600 bg-blue-50 border-blue-200';
case 'todo': return 'text-gray-600 bg-gray-50 border-gray-200';
default: return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
if (!task) return null;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Task Details
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Task Information */}
<Card className="p-4">
<div className="space-y-3">
<h3 className="font-semibold text-lg" data-testid="text-task-details-title">
{task.title}
</h3>
{task.description && (
<p className="text-sm text-muted-foreground" data-testid="text-task-details-description">
{task.description}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className={`text-xs ${getPriorityColor(task.priority)}`}>
{task.priority} priority
</Badge>
<Badge variant="outline" className={`text-xs ${getStatusColor(task.status)}`}>
{task.status}
</Badge>
{task.estimatedHours && (
<Badge variant="outline" className="text-xs">
{task.estimatedHours}h estimated
</Badge>
)}
{getTotalTrackedTime() > 0 && (
<Badge variant="outline" className="text-xs">
{formatMinutesToHours(getTotalTrackedTime())} tracked
</Badge>
)}
</div>
</div>
</Card>
<Tabs defaultValue="notes" className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes" data-testid="tab-notes">
<FileText className="w-4 h-4 mr-2" />
Notes
</TabsTrigger>
<TabsTrigger value="time" data-testid="tab-time">
<Clock className="w-4 h-4 mr-2" />
Time Tracking
</TabsTrigger>
</TabsList>
{/* Notes Tab */}
<TabsContent value="notes" className="space-y-4">
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium">Task Notes</h4>
{!isEditingNotes && (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditingNotes(true)}
data-testid="button-edit-notes"
>
<Edit2 className="w-4 h-4 mr-1" />
Edit
</Button>
)}
</div>
{isEditingNotes ? (
<div className="space-y-3">
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add your notes for this task..."
rows={4}
data-testid="textarea-task-notes"
/>
<div className="flex gap-2">
<Button
onClick={handleSaveNotes}
size="sm"
data-testid="button-save-notes"
>
<Save className="w-4 h-4 mr-1" />
Save
</Button>
<Button
variant="outline"
onClick={() => {
setNotes(task.notes || '');
setIsEditingNotes(false);
}}
size="sm"
data-testid="button-cancel-notes"
>
<X className="w-4 h-4 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div>
{notes.trim() ? (
<div className="whitespace-pre-wrap text-sm" data-testid="text-task-notes">
{notes}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
No notes added yet. Click "Edit" to add notes.
</p>
)}
</div>
)}
</Card>
</TabsContent>
{/* Time Tracking Tab */}
<TabsContent value="time" className="space-y-4">
{/* Total Time Display */}
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Total Time Tracked</h4>
<p className="text-2xl font-mono text-primary" data-testid="text-total-time">
{formatMinutesToHours(getTotalTrackedTime())}
</p>
</div>
<Button
onClick={() => setIsAddingTime(true)}
disabled={isAddingTime}
data-testid="button-add-time"
>
<Plus className="w-4 h-4 mr-1" />
Add Time
</Button>
</div>
</Card>
{/* Add Time Section */}
{isAddingTime && (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">Add Time Entry</h4>
<Button
variant="ghost"
size="sm"
onClick={() => setIsAddingTime(false)}
data-testid="button-cancel-add-time"
>
<X className="w-4 h-4" />
</Button>
</div>
<Tabs value={activeTimeTab} onValueChange={setActiveTimeTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="clock" data-testid="tab-clock-picker">
<Clock className="w-4 h-4 mr-2" />
Clock
</TabsTrigger>
<TabsTrigger value="manual" data-testid="tab-manual-input">
<Timer className="w-4 h-4 mr-2" />
Manual
</TabsTrigger>
</TabsList>
<TabsContent value="clock" className="space-y-4">
<div className="space-y-4">
<div className="text-center">
<div className="text-2xl font-mono" data-testid="text-clock-display">
{formatTime(hours, minutes)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Hours Picker */}
<div className="space-y-2">
<label className="text-sm font-medium text-center block">Hours</label>
<div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
{Array.from({ length: 13 }, (_, i) => (
<Button
key={i}
variant={hours === i ? "default" : "ghost"}
size="sm"
className="w-full"
onClick={() => setHours(i)}
data-testid={`button-hour-${i}`}
>
{i}
</Button>
))}
</div>
</div>
{/* Minutes Picker */}
<div className="space-y-2">
<label className="text-sm font-medium text-center block">Minutes</label>
<div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
{Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => (
<Button
key={minute}
variant={minutes === minute ? "default" : "ghost"}
size="sm"
className="w-full"
onClick={() => setMinutes(minute)}
data-testid={`button-minute-${minute}`}
>
{minute}
</Button>
))}
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<div className="space-y-3">
<div className="text-center">
<div className="text-2xl font-mono" data-testid="text-manual-display">
{getCurrentTimeDisplay()}
</div>
</div>
<div>
<label className="text-sm font-medium block mb-2">Hours (decimal)</label>
<Input
type="number"
step="0.25"
min="0"
max="24"
placeholder="e.g., 1.5 for 1 hour 30 minutes"
value={manualHours}
onChange={(e) => setManualHours(e.target.value)}
data-testid="input-manual-hours"
/>
<p className="text-xs text-muted-foreground mt-1">
Examples: 0.5 = 30min, 1.25 = 1h 15min, 2.75 = 2h 45min
</p>
</div>
</div>
</TabsContent>
</Tabs>
<div>
<label className="text-sm font-medium block mb-2">Description (optional)</label>
<Input
value={timeDescription}
onChange={(e) => setTimeDescription(e.target.value)}
placeholder="What did you work on?"
data-testid="input-time-description"
/>
</div>
<div className="flex gap-3">
<Button
onClick={handleSaveTimeEntry}
disabled={activeTimeTab === 'clock' ? hours === 0 && minutes === 0 : !manualHours.trim()}
className="flex-1"
data-testid="button-save-time-entry"
>
<Save className="w-4 h-4 mr-1" />
Save Time
</Button>
</div>
</div>
</Card>
)}
{/* Time Entries List */}
{timeEntries.length > 0 && (
<Card className="p-4">
<h4 className="font-medium mb-3">Time Entries</h4>
<div className="space-y-2 max-h-64 overflow-y-auto">
{timeEntries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between p-3 border rounded-lg hover-elevate"
data-testid={`time-entry-${entry.id}`}
>
<div className="flex-1">
<div className="flex items-center gap-3">
<span className="font-mono font-medium">
{formatMinutesToHours(entry.timeSpent)}
</span>
<span className="text-sm text-muted-foreground">
{entry.date.toLocaleDateString()}
</span>
</div>
{entry.description && (
<p className="text-xs text-muted-foreground mt-1">
{entry.description}
</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTimeEntry(entry.id)}
data-testid={`button-delete-time-entry-${entry.id}`}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</div>
</Card>
)}
{timeEntries.length === 0 && !isAddingTime && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
No time entries yet. Click "Add Time" to start tracking.
</div>
)}
</TabsContent>
</Tabs>
{/* Close button */}
<div className="flex justify-end pt-4 border-t">
<Button
onClick={handleClose}
variant="outline"
data-testid="button-close-task-details"
>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}