feat: Add Mark as Done button to task details modal
continuous-integration/drone/push Build is passing
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user