Files
task-manager/client/src/components/TaskCreationModal.tsx
T
2025-12-12 08:35:48 +01:00

342 lines
14 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CalendarIcon, Plus, Tag, Check, Link2 } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { parseTaskInput } from '../lib/nlp';
import { Sparkles } from 'lucide-react';
interface TaskCreationModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (task: Partial<Task>) => void;
}
export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreationModalProps) {
const { t } = useTranslation();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [energyLevel, setEnergyLevel] = useState<'low' | 'medium' | 'high'>('medium');
const [estimatedDuration, setEstimatedDuration] = useState<number | undefined>();
const [dueDate, setDueDate] = useState<Date | undefined>();
const [labelId, setLabelId] = useState<string | undefined>();
const [dependencies, setDependencies] = useState<string[]>([]);
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch labels
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels'],
});
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
const handleSave = () => {
if (!title.trim()) {
setError(t('taskCreation.titleRequired') || 'Title is required');
return;
}
const newTask: Partial<Task> = {
title: title.trim(),
description: description.trim() || undefined,
priority,
energyLevel,
estimatedDuration,
dueDate,
labelId,
dependencies,
status: 'todo',
timeTracked: 0,
isTracking: false
};
onSave(newTask);
console.log('New task created:', newTask);
// Reset form
setTitle('');
setDescription('');
setPriority('medium');
setDueDate(undefined);
setDueDate(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
}
};
const handleClose = () => {
setTitle('');
setDescription('');
setPriority('medium');
setDueDate(undefined);
setDueDate(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose} >
<DialogContent className="sm:max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="w-4 h-4" />
{t('taskCreation.title')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder={t('smartTask.placeholder')}
value={title}
onChange={(e) => {
setTitle(e.target.value);
const parsed = parseTaskInput(e.target.value);
if (parsed.priority) setPriority(parsed.priority);
if (parsed.dueDate) setDueDate(parsed.dueDate);
if (parsed.labelName) {
const foundLabel = labels.find(l => l.name.toLowerCase() === parsed.labelName?.toLowerCase());
if (foundLabel) setLabelId(foundLabel.id);
}
if (error) setError(null);
}}
onKeyDown={handleKeyDown}
className="text-base"
data-testid="input-task-title"
autoFocus
/>
<div className="flex items-center gap-1 mt-1">
{title.includes('!') || title.includes('#') ||
title.toLowerCase().includes('tomorrow') || title.toLowerCase().includes('morgen') ||
title.toLowerCase().includes('today') || title.toLowerCase().includes('heute') ? (
<div className="bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-300 text-xs px-2 py-0.5 rounded-full flex items-center gap-1 animate-pulse">
<Sparkles className="w-3 h-3" />
{t('taskCreation.smartInputActive')}
</div>
) : null}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
<div>
<Textarea
placeholder={t('taskCreation.descriptionPlaceholder')}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="resize-none text-sm"
rows={3}
data-testid="input-task-description"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
<SelectTrigger data-testid="select-task-priority">
<SelectValue placeholder={t('taskCreation.priority')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('priority.low')}</SelectItem>
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
<SelectItem value="high">{t('priority.high')}</SelectItem>
</SelectContent>
</Select>
<Select value={energyLevel} onValueChange={(value: 'low' | 'medium' | 'high') => setEnergyLevel(value)}>
<SelectTrigger>
<SelectValue placeholder={t('taskCreation.energy')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('gamification.energy.low')}</SelectItem>
<SelectItem value="medium">{t('gamification.energy.medium')}</SelectItem>
<SelectItem value="high">{t('gamification.energy.high')}</SelectItem>
</SelectContent>
</Select>
<div className="col-span-1">
<Input
type="number"
placeholder={t('taskCreation.minutesPlaceholder')}
value={estimatedDuration || ''}
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
className="text-sm"
/>
</div>
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
<SelectTrigger data-testid="select-task-label">
<SelectValue placeholder={t('taskCreation.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('taskCreation.noLabel')}</SelectItem>
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: label.color }}
/>
{label.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="col-span-2">
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
data-testid="button-due-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dueDate ? dueDate.toLocaleDateString() : t('taskCreation.dueDate')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDate}
onSelect={(date) => {
setDueDate(date);
setIsCalendarOpen(false);
}}
disabled={(date) => date < new Date()}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* Dependencies Selector */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<Link2 className="w-4 h-4" />
{t('dependencies.label')}
</label>
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between h-auto min-h-[40px]"
>
<div className="flex flex-wrap gap-1">
{dependencies.length > 0 ? (
dependencies.map((depId) => {
const task = tasks.find((t) => t.id === depId);
return (
<Badge key={depId} variant="secondary" className="mr-1">
{task?.title || 'Unknown Task'}
<div
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setDependencies(dependencies.filter((id) => id !== depId));
}}
>
<Plus className="h-3 w-3 rotate-45 hover:text-destructive" />
</div>
</Badge>
);
})
) : (
<span className="text-muted-foreground">{t('dependencies.selectPlaceholder')}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search tasks..." />
<CommandList>
<CommandEmpty>No task found.</CommandEmpty>
<CommandGroup heading="Available Tasks">
{tasks
.filter(t => t.status !== 'done') // Only active tasks
.map((task) => (
<CommandItem
key={task.id}
value={task.title}
onSelect={() => {
if (dependencies.includes(task.id)) {
setDependencies(dependencies.filter((id) => id !== task.id));
} else {
setDependencies([...dependencies, task.id]);
}
// Keep the popover open for multiple selection
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
dependencies.includes(task.id)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
<Check className={cn("h-4 w-4")} />
</div>
<span>{task.title}</span>
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1 py-0 capitalize">
{task.status.replace('_', ' ')}
</Badge>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
data-testid="button-cancel"
>
{t('taskCreation.cancel')}
</Button>
<Button
onClick={handleSave}
className="flex-1"
data-testid="button-save-task"
>
{t('taskCreation.create')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}