301 lines
18 KiB
TypeScript
301 lines
18 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { Task, TaskPriority } from '../types';
|
|
import { useLanguage } from '../i18n/LanguageContext';
|
|
|
|
interface AddTaskModalProps {
|
|
mode: 'child' | 'sibling';
|
|
parentTask?: Task;
|
|
siblingTask: Task;
|
|
allTasks: Task[];
|
|
planId: number;
|
|
onSave: (data: {
|
|
task_id: string;
|
|
title: string;
|
|
description: string;
|
|
parent_id?: number | null;
|
|
dependencies: string[];
|
|
durationInHours: number;
|
|
priority?: string;
|
|
dueDate?: string | null;
|
|
sortOrder?: number;
|
|
}) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
const AddTaskModal: React.FC<AddTaskModalProps> = ({ mode, parentTask, siblingTask, allTasks, planId, onSave, onCancel }) => {
|
|
const { t } = useLanguage();
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [taskId, setTaskId] = useState('');
|
|
const [selectedDeps, setSelectedDeps] = useState<string[]>([]);
|
|
const [duration, setDuration] = useState('0');
|
|
const [priority, setPriority] = useState<TaskPriority>('medium');
|
|
const [dueDate, setDueDate] = useState('');
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
const flattenTasksWithDepth = (items: Task[], depth: number = 0): { task: Task; depth: number }[] => {
|
|
const result: { task: Task; depth: number }[] = [];
|
|
for (const t of items) {
|
|
result.push({ task: t, depth });
|
|
if (t.children?.length) {
|
|
result.push(...flattenTasksWithDepth(t.children, depth + 1));
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const availableTasks = flattenTasksWithDepth(allTasks);
|
|
|
|
const toggleDep = (depTaskId: string) => {
|
|
setSelectedDeps(prev =>
|
|
prev.includes(depTaskId)
|
|
? prev.filter(id => id !== depTaskId)
|
|
: [...prev, depTaskId]
|
|
);
|
|
};
|
|
|
|
const handleSave = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const parentId = mode === 'child' ? siblingTask.id : siblingTask.parent_id;
|
|
onSave({
|
|
task_id: taskId || `task-${Date.now()}`,
|
|
title,
|
|
description,
|
|
parent_id: parentId,
|
|
dependencies: selectedDeps,
|
|
durationInHours: parseFloat(duration) || 0,
|
|
priority,
|
|
dueDate: dueDate || null,
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') onCancel();
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [onCancel]);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setDropdownOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const modeLabel = mode === 'child' ? t('addChildTitle') : t('addSiblingTitle');
|
|
const parentLabel = mode === 'child' ? siblingTask.title : (parentTask?.title || t('rootLevel'));
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-slate-900/40 dark:bg-slate-950/70 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
|
aria-modal="true"
|
|
role="dialog"
|
|
onClick={onCancel}
|
|
>
|
|
<div
|
|
className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-lg p-6 md:p-8 relative max-h-[90vh] overflow-y-auto border border-slate-200/80 dark:border-slate-700"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
onClick={onCancel}
|
|
className="absolute top-4 right-4 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
|
|
aria-label={t('close')}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-1">{modeLabel}</h2>
|
|
<p className="text-sm text-slate-400 dark:text-slate-500 mb-6">
|
|
{mode === 'child' ? t('addChildHint', { title: siblingTask.title }) : t('addSiblingHint', { title: siblingTask.title })}
|
|
<span className="text-slate-300 dark:text-slate-600 mx-1">·</span>
|
|
{t('belongsTo')} <span className="text-slate-600 dark:text-slate-300 font-medium">{parentLabel}</span>
|
|
</p>
|
|
|
|
<form onSubmit={handleSave} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="add-task-id" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{t('taskId')}</label>
|
|
<input
|
|
type="text"
|
|
id="add-task-id"
|
|
value={taskId}
|
|
onChange={(e) => setTaskId(e.target.value)}
|
|
className="w-full p-2 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-shadow"
|
|
placeholder={t('taskIdPlaceholder')}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="add-title" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{t('title')} <span className="text-red-400">*</span></label>
|
|
<input
|
|
type="text"
|
|
id="add-title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
className="w-full p-2 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-shadow"
|
|
required
|
|
placeholder={t('titlePlaceholder')}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="add-description" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{t('description')} <span className="text-red-400">*</span></label>
|
|
<textarea
|
|
id="add-description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
className="w-full p-2 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-shadow"
|
|
rows={3}
|
|
required
|
|
placeholder={t('descPlaceholder')}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label htmlFor="add-duration" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{t('durationHours')}</label>
|
|
<input
|
|
type="number"
|
|
id="add-duration"
|
|
value={duration}
|
|
onChange={(e) => setDuration(e.target.value)}
|
|
className="w-full p-2 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-shadow"
|
|
min="0"
|
|
step="0.5"
|
|
placeholder={t('durationPlaceholder')}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="add-priority" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{t('priority')}</label>
|
|
<select
|
|
id="add-priority"
|
|
value={priority}
|
|
onChange={(e) => setPriority(e.target.value as TaskPriority)}
|
|
className="w-full p-2 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-green-500"
|
|
>
|
|
<option value="low">{t('priorityLow')}</option>
|
|
<option value="medium">{t('priorityMedium')}</option>
|
|
<option value="high">{t('priorityHigh')}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="add-due-date" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{t('dueDate')}</label>
|
|
<input
|
|
type="date"
|
|
id="add-due-date"
|
|
value={dueDate}
|
|
onChange={(e) => setDueDate(e.target.value)}
|
|
className="w-full p-2 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-shadow"
|
|
/>
|
|
</div>
|
|
<div ref={dropdownRef}>
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{t('dependencies')}</label>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
className="w-full p-2 bg-white dark:bg-slate-700 text-left border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-shadow flex items-center justify-between min-h-[42px]"
|
|
>
|
|
<span className={`${selectedDeps.length === 0 ? 'text-slate-400 dark:text-slate-500' : 'text-slate-900 dark:text-slate-100'} truncate`}>
|
|
{selectedDeps.length === 0
|
|
? t('selectDeps')
|
|
: selectedDeps.map(depId => {
|
|
const item = availableTasks.find(at => at.task.task_id === depId);
|
|
return item ? item.task.title : depId;
|
|
}).join(', ')
|
|
}
|
|
</span>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className={`h-4 w-4 text-slate-400 transition-transform flex-shrink-0 ml-2 ${dropdownOpen ? 'rotate-180' : ''}`}
|
|
fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</button>
|
|
{dropdownOpen && (
|
|
<div className="absolute z-10 mt-1 w-full bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg shadow-lg max-h-52 overflow-y-auto">
|
|
{availableTasks.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-slate-400 dark:text-slate-500">{t('noTasks')}</div>
|
|
) : (
|
|
availableTasks.map(({ task: t, depth }) => (
|
|
<label
|
|
key={t.id}
|
|
className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-600 cursor-pointer transition-colors"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedDeps.includes(t.task_id)}
|
|
onChange={() => toggleDep(t.task_id)}
|
|
className="w-4 h-4 rounded border-slate-300 dark:border-slate-500 text-green-600 focus:ring-green-500 flex-shrink-0"
|
|
/>
|
|
<span className="flex items-center flex-shrink-0" style={{ width: depth * 20 }}>
|
|
{depth > 0 && (
|
|
<span className="flex items-center w-full">
|
|
{Array.from({ length: depth }).map((_, i) => (
|
|
<span key={i} className="border-l-2 border-slate-200 dark:border-slate-600 h-4" style={{ width: 20 }} />
|
|
))}
|
|
<svg className="w-3 h-3 text-slate-300 dark:text-slate-500 flex-shrink-0" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<path d="M0 0v6h6" />
|
|
</svg>
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="text-xs font-mono text-slate-400 dark:text-slate-500 flex-shrink-0">{t.task_id}</span>
|
|
<span className="text-sm text-slate-700 dark:text-slate-200 truncate">{t.title}</span>
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{selectedDeps.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
{selectedDeps.map(depId => {
|
|
const item = availableTasks.find(at => at.task.task_id === depId);
|
|
return (
|
|
<span
|
|
key={depId}
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-xs font-medium border border-green-200 dark:border-green-800"
|
|
>
|
|
{item ? item.task.title : depId}
|
|
<button type="button" onClick={() => toggleDep(depId)} className="text-green-500 hover:text-green-700 dark:hover:text-green-300 transition-colors">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-4 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 font-semibold px-4 py-2 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
|
|
>
|
|
{t('cancel')}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="bg-green-600 text-white font-semibold px-4 py-2 rounded-lg hover:bg-green-700 transition-colors"
|
|
>
|
|
{t('addTask')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AddTaskModal;
|