feat: Add Mark as Done button to task details modal
continuous-integration/drone/push Build is passing

Add a "Mark as Done" button next to the Save button in the task details
modal. When clicked, if no time has been tracked for the task, a dialog
prompts the user to enter time spent before marking the task as done.
The dialog supports both clock-style (hours/minutes pickers) and manual
(decimal hours) time entry.
This commit is contained in:
Paul Nothaft
2026-01-19 22:12:23 +01:00
parent 88cd2a5c88
commit ad50b97f44
3 changed files with 252 additions and 6 deletions
+234 -4
View File
@@ -13,7 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { cn } from "@/lib/utils";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useQuery } from '@tanstack/react-query';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar as CalendarIcon, Tag, Link2, Check, LayoutList, Trash2, ArrowRight } from 'lucide-react';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar as CalendarIcon, Tag, Link2, Check, LayoutList, Trash2, ArrowRight, CheckCircle2 } from 'lucide-react';
import { Calendar } from '@/components/ui/calendar';
import { format } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
@@ -75,7 +75,7 @@ export default function TaskDetailsModal({
const [notes, setNotes] = useState('');
const [isEditingNotes, setIsEditingNotes] = useState(false);
// Time reporting state
// Time reporting state
const [isAddingTime, setIsAddingTime] = useState(false);
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
@@ -83,6 +83,13 @@ export default function TaskDetailsModal({
const [timeDescription, setTimeDescription] = useState('');
const [activeTimeTab, setActiveTimeTab] = useState('clock');
// Mark as done flow state
const [showTimeRequiredDialog, setShowTimeRequiredDialog] = useState(false);
const [markDoneHours, setMarkDoneHours] = useState(0);
const [markDoneMinutes, setMarkDoneMinutes] = useState(0);
const [markDoneManualHours, setMarkDoneManualHours] = useState('');
const [markDoneTimeTab, setMarkDoneTimeTab] = useState('clock');
// Time entries state - simulate from timeTracked for display
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
@@ -130,9 +137,86 @@ export default function TaskDetailsModal({
setMinutes(0);
setManualHours('');
setTimeDescription('');
setShowTimeRequiredDialog(false);
setMarkDoneHours(0);
setMarkDoneMinutes(0);
setMarkDoneManualHours('');
onClose();
};
// Handle Mark as Done button click
const handleMarkAsDone = () => {
if (!task) return;
// Check if time has been tracked
if (!task.timeTracked || task.timeTracked === 0) {
// Show dialog to enter time first
setShowTimeRequiredDialog(true);
setMarkDoneHours(0);
setMarkDoneMinutes(0);
setMarkDoneManualHours('');
setMarkDoneTimeTab('clock');
} else {
// Time already tracked, mark as done directly
completeTaskAsDone();
}
};
// Complete the task as done
const completeTaskAsDone = () => {
if (!task) return;
const updatedTask: Task = {
...task,
title: editedTitle,
description: editedDescription || null,
status: 'done',
priority: editedPriority,
labelId: editedLabelId,
dueDate: editedDueDate ? new Date(editedDueDate) : null,
startDate: editedStartDate ? new Date(editedStartDate) : null,
estimatedDuration: editedEstimatedDuration ?? null,
dependencies: editedDependencies
};
onSave(updatedTask);
handleClose();
};
// Handle saving time and then marking as done
const handleSaveTimeAndMarkDone = () => {
if (!task) return;
let totalMinutes = 0;
if (markDoneTimeTab === 'clock') {
totalMinutes = markDoneHours * 60 + markDoneMinutes;
} else {
const parsedHours = parseFloat(markDoneManualHours) || 0;
totalMinutes = Math.round(parsedHours * 60);
}
if (totalMinutes <= 0) return;
// Create updated task with time and done status
const updatedTask: Task = {
...task,
title: editedTitle,
description: editedDescription || null,
status: 'done',
priority: editedPriority,
labelId: editedLabelId,
dueDate: editedDueDate ? new Date(editedDueDate) : null,
startDate: editedStartDate ? new Date(editedStartDate) : null,
estimatedDuration: editedEstimatedDuration ?? null,
dependencies: editedDependencies,
timeTracked: (task.timeTracked || 0) + totalMinutes
};
onSave(updatedTask);
setShowTimeRequiredDialog(false);
handleClose();
};
const handleSaveTaskEdit = () => {
if (!task) return;
@@ -322,6 +406,7 @@ export default function TaskDetailsModal({
const isSubtask = !!task.parentTaskId;
return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-[95vw] max-w-lg sm:max-w-2xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader>
@@ -717,7 +802,7 @@ export default function TaskDetailsModal({
</Popover>
</div>
{/* Save Button */}
{/* Save and Mark as Done Buttons */}
<div className="flex gap-2 pt-2">
<Button
onClick={handleSaveTaskEdit}
@@ -727,6 +812,18 @@ export default function TaskDetailsModal({
<Save className="w-4 h-4 mr-1" />
{t('taskDetails.save')}
</Button>
{editedStatus !== 'done' && (
<Button
onClick={handleMarkAsDone}
disabled={!editedTitle.trim()}
variant="default"
className="bg-green-600 hover:bg-green-700"
data-testid="button-mark-as-done"
>
<CheckCircle2 className="w-4 h-4 mr-1" />
{t('taskDetails.markAsDone', 'Mark as Done')}
</Button>
)}
</div>
</div>
</Card>
@@ -1013,6 +1110,139 @@ export default function TaskDetailsModal({
</div>
</div>
</DialogContent>
</Dialog >
</Dialog>
{/* Time Required Dialog - shown when marking as done without time */}
<Dialog open={showTimeRequiredDialog} onOpenChange={setShowTimeRequiredDialog}>
<DialogContent className="w-[95vw] max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
{t('taskDetails.timeRequired', 'Time Required')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('taskDetails.timeRequiredDesc', 'Please enter the time spent on this task before marking it as done.')}
</p>
<Tabs value={markDoneTimeTab} onValueChange={setMarkDoneTimeTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="clock">
<Clock className="w-4 h-4 mr-2" />
{t('taskDetails.clock', 'Clock')}
</TabsTrigger>
<TabsTrigger value="manual">
<Timer className="w-4 h-4 mr-2" />
{t('taskDetails.manual', '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">
{formatTime(markDoneHours, markDoneMinutes)}
</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">{t('taskDetails.hours', '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={markDoneHours === i ? "default" : "ghost"}
size="sm"
className="w-full"
onClick={() => setMarkDoneHours(i)}
>
{i}
</Button>
))}
</div>
</div>
{/* Minutes Picker */}
<div className="space-y-2">
<label className="text-sm font-medium text-center block">{t('taskDetails.minutes', '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={markDoneMinutes === minute ? "default" : "ghost"}
size="sm"
className="w-full"
onClick={() => setMarkDoneMinutes(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">
{(() => {
const parsedHours = parseFloat(markDoneManualHours) || 0;
const h = Math.floor(parsedHours);
const m = Math.round((parsedHours - h) * 60);
return formatTime(h, m);
})()}
</div>
</div>
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.hoursDecimal', 'Hours (decimal)')}</label>
<Input
type="number"
step="0.25"
min="0"
max="24"
placeholder="e.g., 1.5 for 1 hour 30 minutes"
value={markDoneManualHours}
onChange={(e) => setMarkDoneManualHours(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
{t('taskDetails.timeExamples', 'Examples: 0.5 = 30min, 1.25 = 1h 15min')}
</p>
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex gap-2 pt-2">
<Button
onClick={handleSaveTimeAndMarkDone}
disabled={
markDoneTimeTab === 'clock'
? markDoneHours === 0 && markDoneMinutes === 0
: !markDoneManualHours.trim() || parseFloat(markDoneManualHours) <= 0
}
className="flex-1 bg-green-600 hover:bg-green-700"
data-testid="button-save-time-and-done"
>
<CheckCircle2 className="w-4 h-4 mr-1" />
{t('taskDetails.saveAndMarkDone', 'Save & Mark as Done')}
</Button>
<Button
variant="outline"
onClick={() => setShowTimeRequiredDialog(false)}
>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+9 -1
View File
@@ -197,7 +197,15 @@
"durationUnits": {
"m": "Min",
"h": "Std"
}
},
"markAsDone": "Als erledigt markieren",
"timeRequired": "Zeitangabe erforderlich",
"timeRequiredDesc": "Bitte geben Sie die für diese Aufgabe aufgewendete Zeit ein, bevor Sie sie als erledigt markieren.",
"hours": "Stunden",
"minutes": "Minuten",
"hoursDecimal": "Stunden (dezimal)",
"timeExamples": "Beispiele: 0.5 = 30 Min, 1.25 = 1 Std 15 Min",
"saveAndMarkDone": "Speichern & Als erledigt markieren"
},
"taskCard": {
"play": "Timer starten",
+9 -1
View File
@@ -195,7 +195,15 @@
"durationUnits": {
"m": "m",
"h": "h"
}
},
"markAsDone": "Mark as Done",
"timeRequired": "Time Required",
"timeRequiredDesc": "Please enter the time spent on this task before marking it as done.",
"hours": "Hours",
"minutes": "Minutes",
"hoursDecimal": "Hours (decimal)",
"timeExamples": "Examples: 0.5 = 30min, 1.25 = 1h 15min",
"saveAndMarkDone": "Save & Mark as Done"
},
"taskCard": {
"play": "Start timer",