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 { cn } from "@/lib/utils";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { Calendar } from '@/components/ui/calendar';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { de, enUS } from 'date-fns/locale';
|
import { de, enUS } from 'date-fns/locale';
|
||||||
@@ -83,6 +83,13 @@ export default function TaskDetailsModal({
|
|||||||
const [timeDescription, setTimeDescription] = useState('');
|
const [timeDescription, setTimeDescription] = useState('');
|
||||||
const [activeTimeTab, setActiveTimeTab] = useState('clock');
|
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
|
// Time entries state - simulate from timeTracked for display
|
||||||
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
|
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
|
||||||
|
|
||||||
@@ -130,9 +137,86 @@ export default function TaskDetailsModal({
|
|||||||
setMinutes(0);
|
setMinutes(0);
|
||||||
setManualHours('');
|
setManualHours('');
|
||||||
setTimeDescription('');
|
setTimeDescription('');
|
||||||
|
setShowTimeRequiredDialog(false);
|
||||||
|
setMarkDoneHours(0);
|
||||||
|
setMarkDoneMinutes(0);
|
||||||
|
setMarkDoneManualHours('');
|
||||||
onClose();
|
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 = () => {
|
const handleSaveTaskEdit = () => {
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
@@ -322,6 +406,7 @@ export default function TaskDetailsModal({
|
|||||||
const isSubtask = !!task.parentTaskId;
|
const isSubtask = !!task.parentTaskId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<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">
|
<DialogContent className="w-[95vw] max-w-lg sm:max-w-2xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -717,7 +802,7 @@ export default function TaskDetailsModal({
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save and Mark as Done Buttons */}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSaveTaskEdit}
|
onClick={handleSaveTaskEdit}
|
||||||
@@ -727,6 +812,18 @@ export default function TaskDetailsModal({
|
|||||||
<Save className="w-4 h-4 mr-1" />
|
<Save className="w-4 h-4 mr-1" />
|
||||||
{t('taskDetails.save')}
|
{t('taskDetails.save')}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1014,5 +1111,138 @@ export default function TaskDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</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": {
|
"durationUnits": {
|
||||||
"m": "Min",
|
"m": "Min",
|
||||||
"h": "Std"
|
"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": {
|
"taskCard": {
|
||||||
"play": "Timer starten",
|
"play": "Timer starten",
|
||||||
|
|||||||
@@ -195,7 +195,15 @@
|
|||||||
"durationUnits": {
|
"durationUnits": {
|
||||||
"m": "m",
|
"m": "m",
|
||||||
"h": "h"
|
"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": {
|
"taskCard": {
|
||||||
"play": "Start timer",
|
"play": "Start timer",
|
||||||
|
|||||||
Reference in New Issue
Block a user