Fix project loading, task detail panel, collapsible sidebar, due dates
This commit is contained in:
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Todo App - Task management made simple" />
|
<meta name="description" content="Todo App - Task management made simple" />
|
||||||
<title>Todo App</title>
|
<title>Todo App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B4OukgoH.js"></script>
|
<script type="module" crossorigin src="/assets/index-vdiwxbr4.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CVystegy.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cuyvk5mt.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Plus, Calendar, Flag, Tag, X, UserCircle } from 'lucide-react';
|
import { Plus, Calendar, Flag, Tag, X } from 'lucide-react';
|
||||||
import type { Priority } from '@/types';
|
import type { Priority } from '@/types';
|
||||||
import { cn, getPriorityColor } from '@/lib/utils';
|
import { cn, getPriorityColor } from '@/lib/utils';
|
||||||
import { useTasksStore } from '@/stores/tasks';
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
@@ -117,25 +117,28 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
|||||||
{/* Options row */}
|
{/* Options row */}
|
||||||
<div className="flex items-center gap-2 mt-3 flex-wrap">
|
<div className="flex items-center gap-2 mt-3 flex-wrap">
|
||||||
{/* Due date */}
|
{/* Due date */}
|
||||||
<div className="relative">
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={dueDate}
|
value={dueDate}
|
||||||
onChange={(e) => setDueDate(e.target.value)}
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded border transition-colors',
|
'text-xs rounded border px-2 py-1 outline-none focus:border-blue-400 cursor-pointer bg-white',
|
||||||
dueDate
|
dueDate
|
||||||
? 'border-blue-200 bg-blue-50 text-blue-600'
|
? 'border-blue-200 bg-blue-50 text-blue-600'
|
||||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<Calendar className="w-3.5 h-3.5" />
|
{dueDate && (
|
||||||
{dueDate ? new Date(dueDate).toLocaleDateString() : 'Due date'}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
onClick={() => setDueDate('')}
|
||||||
|
className="p-0.5 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority selector */}
|
{/* Priority selector */}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet, Navigate } from 'react-router-dom';
|
import { Outlet, Navigate } from 'react-router-dom';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { TaskDetail } from './TaskDetail';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useTasksStore } from '@/stores/tasks';
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
const { fetchProjects, fetchLabels } = useTasksStore();
|
const { fetchProjects, fetchLabels, selectedTask, setSelectedTask } = useTasksStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
@@ -36,6 +37,12 @@ export function Layout() {
|
|||||||
<main className="flex-1 overflow-y-auto p-8">
|
<main className="flex-1 overflow-y-auto p-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
{selectedTask && (
|
||||||
|
<TaskDetail
|
||||||
|
task={selectedTask}
|
||||||
|
onClose={() => setSelectedTask(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
|
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
|
||||||
Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check
|
Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check,
|
||||||
|
PanelLeftClose, PanelLeftOpen
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
@@ -13,6 +14,8 @@ const PROJECT_COLORS = [
|
|||||||
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -24,6 +27,13 @@ export function Sidebar() {
|
|||||||
const [newProjectName, setNewProjectName] = useState('');
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
const [newProjectColor, setNewProjectColor] = useState(PROJECT_COLORS[0]);
|
const [newProjectColor, setNewProjectColor] = useState(PROJECT_COLORS[0]);
|
||||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
const newProjectInputRef = useRef<HTMLInputElement>(null);
|
const newProjectInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,6 +42,14 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}, [showNewProject]);
|
}, [showNewProject]);
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
const next = !isCollapsed;
|
||||||
|
setIsCollapsed(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateProject = async () => {
|
const handleCreateProject = async () => {
|
||||||
if (!newProjectName.trim() || isCreatingProject) return;
|
if (!newProjectName.trim() || isCreatingProject) return;
|
||||||
setIsCreatingProject(true);
|
setIsCreatingProject(true);
|
||||||
@@ -68,6 +86,92 @@ export function Sidebar() {
|
|||||||
{ path: '/upcoming', icon: CalendarDays, label: 'Upcoming', color: '#8b5cf6' },
|
{ path: '/upcoming', icon: CalendarDays, label: 'Upcoming', color: '#8b5cf6' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Collapsed sidebar
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<aside className="w-14 h-screen bg-gray-50 border-r border-gray-200 flex flex-col items-center">
|
||||||
|
{/* Expand button */}
|
||||||
|
<div className="p-2 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title="Expand sidebar"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User avatar */}
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium" title={user?.name}>
|
||||||
|
{user?.name?.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav icons */}
|
||||||
|
<nav className="flex-1 py-2 space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center w-10 h-10 rounded-lg transition-colors',
|
||||||
|
location.pathname === item.path
|
||||||
|
? 'bg-blue-50 text-blue-600'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4" style={{ color: item.color }} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Project icons */}
|
||||||
|
<div className="pt-3 border-t border-gray-200 mt-3 space-y-1">
|
||||||
|
{regularProjects.slice(0, 5).map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
to={`/project/${project.id}`}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center w-10 h-10 rounded-lg transition-colors',
|
||||||
|
location.pathname.startsWith(`/project/${project.id}`)
|
||||||
|
? 'bg-blue-50'
|
||||||
|
: 'hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
title={project.name}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded"
|
||||||
|
style={{ backgroundColor: project.color }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Bottom icons */}
|
||||||
|
<div className="p-2 border-t border-gray-200 space-y-1">
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-lg text-gray-700 hover:bg-gray-100"
|
||||||
|
title="Admin"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-lg text-gray-700 hover:bg-gray-100"
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 h-screen bg-gray-50 border-r border-gray-200 flex flex-col">
|
<aside className="w-64 h-screen bg-gray-50 border-r border-gray-200 flex flex-col">
|
||||||
{/* User section */}
|
{/* User section */}
|
||||||
@@ -80,6 +184,13 @@ export function Sidebar() {
|
|||||||
<p className="text-sm font-medium text-gray-900 truncate">{user?.name}</p>
|
<p className="text-sm font-medium text-gray-900 truncate">{user?.name}</p>
|
||||||
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title="Collapse sidebar"
|
||||||
|
>
|
||||||
|
<PanelLeftClose className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
377
src/components/TaskDetail.tsx
Normal file
377
src/components/TaskDetail.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
X, Check, Trash2, Calendar, Flag, User, FolderOpen,
|
||||||
|
Tag, MessageSquare, ChevronRight, AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { Task, Priority, Comment } from '@/types';
|
||||||
|
import { cn, formatDate, getPriorityColor, getPriorityLabel } from '@/lib/utils';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface TaskDetailProps {
|
||||||
|
task: Task;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetail({ task, onClose }: TaskDetailProps) {
|
||||||
|
const { updateTask, deleteTask, toggleComplete, projects, users, fetchUsers } = useTasksStore();
|
||||||
|
const [title, setTitle] = useState(task.title);
|
||||||
|
const [description, setDescription] = useState(task.description || '');
|
||||||
|
const [dueDate, setDueDate] = useState(task.dueDate || '');
|
||||||
|
const [priority, setPriority] = useState<Priority>(task.priority);
|
||||||
|
const [assigneeId, setAssigneeId] = useState(task.assigneeId || '');
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(task.title);
|
||||||
|
setDescription(task.description || '');
|
||||||
|
setDueDate(task.dueDate || '');
|
||||||
|
setPriority(task.priority);
|
||||||
|
setAssigneeId(task.assigneeId || '');
|
||||||
|
}, [task]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (users.length === 0) {
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getComments(task.id).then(setComments).catch(() => setComments([]));
|
||||||
|
}, [task.id]);
|
||||||
|
|
||||||
|
// Save on blur for title/description
|
||||||
|
const handleTitleBlur = () => {
|
||||||
|
if (title.trim() && title !== task.title) {
|
||||||
|
updateTask(task.id, { title: title.trim() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDescriptionBlur = () => {
|
||||||
|
if (description !== (task.description || '')) {
|
||||||
|
updateTask(task.id, { description: description.trim() || undefined });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDueDateChange = (value: string) => {
|
||||||
|
setDueDate(value);
|
||||||
|
updateTask(task.id, { dueDate: value || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriorityChange = (value: Priority) => {
|
||||||
|
setPriority(value);
|
||||||
|
updateTask(task.id, { priority: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssigneeChange = (value: string) => {
|
||||||
|
setAssigneeId(value);
|
||||||
|
updateTask(task.id, { assigneeId: value || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteTask(task.id);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete task:', error);
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
await toggleComplete(task.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const taskProject = projects.find(p => p.id === task.projectId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/20 z-40"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="fixed right-0 top-0 h-full w-full max-w-lg bg-white shadow-xl z-50 flex flex-col animate-slide-in"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleComplete}
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors',
|
||||||
|
task.isCompleted
|
||||||
|
? 'bg-gray-400 border-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: !task.isCompleted ? getPriorityColor(task.priority) : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.isCompleted && <Check className="w-4 h-4 text-white" />}
|
||||||
|
</button>
|
||||||
|
<span className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
task.isCompleted ? 'text-gray-500' : 'text-gray-900'
|
||||||
|
)}>
|
||||||
|
{task.isCompleted ? 'Completed' : 'Mark complete'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<input
|
||||||
|
ref={titleRef}
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onBlur={handleTitleBlur}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && titleRef.current?.blur()}
|
||||||
|
className="w-full text-lg font-semibold text-gray-900 border-none outline-none placeholder-gray-400 bg-transparent"
|
||||||
|
placeholder="Task name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
onBlur={handleDescriptionBlur}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full text-sm text-gray-600 border border-gray-200 rounded-lg p-2 outline-none focus:border-blue-400 resize-none placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Properties */}
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
{/* Project */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FolderOpen className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-gray-500 w-20">Project</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{taskProject && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded"
|
||||||
|
style={{ backgroundColor: taskProject.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900">{taskProject.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due Date */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Calendar className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-gray-500 w-20">Due date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => handleDueDateChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'text-sm border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400',
|
||||||
|
dueDate ? 'text-gray-900' : 'text-gray-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{dueDate && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDueDateChange('')}
|
||||||
|
className="p-0.5 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Flag className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-gray-500 w-20">Priority</span>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => handlePriorityChange(e.target.value as Priority)}
|
||||||
|
className="text-sm border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400 bg-white cursor-pointer"
|
||||||
|
style={{ color: getPriorityColor(priority) }}
|
||||||
|
>
|
||||||
|
<option value="p1">Priority 1</option>
|
||||||
|
<option value="p2">Priority 2</option>
|
||||||
|
<option value="p3">Priority 3</option>
|
||||||
|
<option value="p4">Priority 4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<User className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-gray-500 w-20">Assignee</span>
|
||||||
|
<select
|
||||||
|
value={assigneeId}
|
||||||
|
onChange={(e) => handleAssigneeChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'text-sm border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400 bg-white cursor-pointer',
|
||||||
|
assigneeId ? 'text-gray-900' : 'text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{task.taskLabels && task.taskLabels.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Tag className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-gray-500 w-20">Labels</span>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{task.taskLabels.map(({ label }) => (
|
||||||
|
<span
|
||||||
|
key={label.id}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${label.color}20`,
|
||||||
|
color: label.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtasks */}
|
||||||
|
{task.subtasks && task.subtasks.length > 0 && (
|
||||||
|
<div className="pt-4 border-t border-gray-100">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
Subtasks ({task.subtasks.filter(s => s.isCompleted).length}/{task.subtasks.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1 ml-6">
|
||||||
|
{task.subtasks.map((subtask) => (
|
||||||
|
<div key={subtask.id} className="flex items-center gap-2 py-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 rounded-full border-2 flex items-center justify-center',
|
||||||
|
subtask.isCompleted
|
||||||
|
? 'bg-gray-400 border-gray-400'
|
||||||
|
: 'border-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{subtask.isCompleted && <Check className="w-2.5 h-2.5 text-white" />}
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'text-sm',
|
||||||
|
subtask.isCompleted ? 'text-gray-500 line-through' : 'text-gray-900'
|
||||||
|
)}>
|
||||||
|
{subtask.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<div className="pt-4 border-t border-gray-100">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3 flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
Comments ({comments.length})
|
||||||
|
</h3>
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 ml-6">No comments yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 ml-6">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-[10px] font-medium">
|
||||||
|
{comment.user?.name?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
{comment.user?.name || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(comment.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">{comment.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 px-4 py-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
Created {new Date(task.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-red-600 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
Delete this task?
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-2 py-1 text-xs text-gray-600 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -116,3 +116,17 @@ body {
|
|||||||
.priority-dot-2 { background: var(--priority-2); }
|
.priority-dot-2 { background: var(--priority-2); }
|
||||||
.priority-dot-3 { background: var(--priority-3); }
|
.priority-dot-3 { background: var(--priority-3); }
|
||||||
.priority-dot-4 { background: var(--priority-4); }
|
.priority-dot-4 { background: var(--priority-4); }
|
||||||
|
|
||||||
|
/* Slide-in animation for task detail panel */
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slide-in-right 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user