Add track project feature with sidebar nav and dashboard widget

Adds is_tracked boolean to projects, expandable tracked projects
in sidebar navigation, pin toggle on project cards/detail, and a
dashboard widget showing upcoming tasks from tracked projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-23 01:20:36 +08:00
parent 7ae19a7cfe
commit 819a4689b8
10 changed files with 379 additions and 14 deletions

View File

@ -0,0 +1,24 @@
"""add is_tracked to projects
Revision ID: 012
Revises: 011
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "012"
down_revision = "011"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("projects", sa.Column("is_tracked", sa.Boolean(), server_default=sa.false(), nullable=False))
def downgrade() -> None:
op.drop_column("projects", "is_tracked")

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Date, func from sqlalchemy import Boolean, String, Text, Date, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List from typing import Optional, List
@ -14,6 +14,7 @@ class Project(Base):
status: Mapped[str] = mapped_column(String(20), default="not_started") status: Mapped[str] = mapped_column(String(20), default="not_started")
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
is_tracked: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -1,15 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import List from typing import List, Optional
from datetime import date, timedelta
from pydantic import BaseModel from pydantic import BaseModel
from app.database import get_db from app.database import get_db
from app.models.project import Project from app.models.project import Project
from app.models.project_task import ProjectTask from app.models.project_task import ProjectTask
from app.models.task_comment import TaskComment from app.models.task_comment import TaskComment
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
from app.routers.auth import get_current_session from app.routers.auth import get_current_session
@ -43,17 +44,64 @@ def _task_load_options():
@router.get("/", response_model=List[ProjectResponse]) @router.get("/", response_model=List[ProjectResponse])
async def get_projects( async def get_projects(
tracked: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Get all projects with their tasks.""" """Get all projects with their tasks. Optionally filter by tracked status."""
query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc()) query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc())
if tracked is not None:
query = query.where(Project.is_tracked == tracked)
result = await db.execute(query) result = await db.execute(query)
projects = result.scalars().unique().all() projects = result.scalars().unique().all()
return projects return projects
@router.get("/tracked-tasks", response_model=List[TrackedTaskResponse])
async def get_tracked_tasks(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get tasks and subtasks from tracked projects with due dates within the next N days."""
today = date.today()
cutoff = today + timedelta(days=days)
query = (
select(ProjectTask)
.join(Project, ProjectTask.project_id == Project.id)
.options(
selectinload(ProjectTask.project),
selectinload(ProjectTask.parent_task),
)
.where(
Project.is_tracked == True,
ProjectTask.due_date.isnot(None),
ProjectTask.due_date >= today,
ProjectTask.due_date <= cutoff,
ProjectTask.status != "completed",
)
.order_by(ProjectTask.due_date.asc())
)
result = await db.execute(query)
tasks = result.scalars().unique().all()
return [
TrackedTaskResponse(
id=t.id,
title=t.title,
status=t.status,
priority=t.priority,
due_date=t.due_date,
project_name=t.project.name,
project_id=t.project_id,
parent_task_title=t.parent_task.title if t.parent_task else None,
)
for t in tasks
]
@router.post("/", response_model=ProjectResponse, status_code=201) @router.post("/", response_model=ProjectResponse, status_code=201)
async def create_project( async def create_project(
project: ProjectCreate, project: ProjectCreate,

View File

@ -12,6 +12,7 @@ class ProjectCreate(BaseModel):
status: ProjectStatus = "not_started" status: ProjectStatus = "not_started"
color: Optional[str] = None color: Optional[str] = None
due_date: Optional[date] = None due_date: Optional[date] = None
is_tracked: bool = False
class ProjectUpdate(BaseModel): class ProjectUpdate(BaseModel):
@ -20,6 +21,7 @@ class ProjectUpdate(BaseModel):
status: Optional[ProjectStatus] = None status: Optional[ProjectStatus] = None
color: Optional[str] = None color: Optional[str] = None
due_date: Optional[date] = None due_date: Optional[date] = None
is_tracked: Optional[bool] = None
class ProjectResponse(BaseModel): class ProjectResponse(BaseModel):
@ -29,8 +31,22 @@ class ProjectResponse(BaseModel):
status: str status: str
color: Optional[str] color: Optional[str]
due_date: Optional[date] due_date: Optional[date]
is_tracked: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
tasks: List[ProjectTaskResponse] = [] tasks: List[ProjectTaskResponse] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class TrackedTaskResponse(BaseModel):
id: int
title: str
status: str
priority: str
due_date: date
project_name: str
project_id: int
parent_task_title: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@ -12,6 +12,7 @@ import UpcomingWidget from './UpcomingWidget';
import WeekTimeline from './WeekTimeline'; import WeekTimeline from './WeekTimeline';
import DayBriefing from './DayBriefing'; import DayBriefing from './DayBriefing';
import CountdownWidget from './CountdownWidget'; import CountdownWidget from './CountdownWidget';
import TrackedProjectsWidget from './TrackedProjectsWidget';
import EventForm from '../calendar/EventForm'; import EventForm from '../calendar/EventForm';
import TodoForm from '../todos/TodoForm'; import TodoForm from '../todos/TodoForm';
import ReminderForm from '../reminders/ReminderForm'; import ReminderForm from '../reminders/ReminderForm';
@ -236,6 +237,11 @@ export default function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Tracked Projects */}
<div className="animate-slide-up" style={{ animationDelay: '200ms', animationFillMode: 'backwards' }}>
<TrackedProjectsWidget />
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,109 @@
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { format, parseISO, isPast, isToday } from 'date-fns';
import { FolderKanban } from 'lucide-react';
import api from '@/lib/api';
import type { TrackedTask } from '@/types';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
const priorityColors: Record<string, string> = {
high: 'bg-red-400',
medium: 'bg-yellow-400',
low: 'bg-green-400',
none: 'bg-gray-400',
};
const statusBadgeColors: Record<string, string> = {
pending: 'bg-gray-500/10 text-gray-400',
in_progress: 'bg-purple-500/10 text-purple-400',
blocked: 'bg-red-500/10 text-red-400',
review: 'bg-yellow-500/10 text-yellow-400',
on_hold: 'bg-orange-500/10 text-orange-400',
};
const statusLabels: Record<string, string> = {
pending: 'Pending',
in_progress: 'Active',
blocked: 'Blocked',
review: 'Review',
on_hold: 'Hold',
};
export default function TrackedProjectsWidget() {
const navigate = useNavigate();
const { settings } = useSettings();
const days = settings?.upcoming_days || 7;
const { data: tasks } = useQuery({
queryKey: ['tracked-tasks', days],
queryFn: async () => {
const { data } = await api.get<TrackedTask[]>(`/projects/tracked-tasks?days=${days}`);
return data;
},
});
if (!tasks || tasks.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-purple-500/10">
<FolderKanban className="h-4 w-4 text-purple-400" />
</div>
Tracked Projects
</CardTitle>
<span className="text-xs text-muted-foreground">{days} days</span>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="max-h-[400px] -mr-2 pr-2">
<div className="space-y-0.5">
{tasks.map((task) => {
const dueDate = parseISO(task.due_date);
const overdue = isPast(dueDate) && !isToday(dueDate);
return (
<div
key={task.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150 cursor-pointer"
onClick={() => navigate(`/projects/${task.project_id}`)}
>
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', priorityColors[task.priority] || 'bg-gray-400')} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
{task.parent_task_title && (
<span className="text-muted-foreground font-normal"> </span>
)}
{task.title}
</span>
{task.parent_task_title && (
<span className="text-[11px] text-muted-foreground truncate block">{task.parent_task_title}</span>
)}
</div>
<span className="text-[11px] text-muted-foreground truncate shrink-0 max-w-[5rem]">{task.project_name}</span>
<span className={cn(
'text-xs shrink-0 whitespace-nowrap tabular-nums',
overdue ? 'text-red-400' : isToday(dueDate) ? 'text-accent' : 'text-muted-foreground'
)}>
{isToday(dueDate) ? 'Today' : format(dueDate, 'MMM d')}
</span>
<span className={cn(
'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0',
statusBadgeColors[task.status] || 'bg-gray-500/10 text-gray-400'
)}>
{statusLabels[task.status] || task.status}
</span>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}

View File

@ -1,4 +1,6 @@
import { NavLink, useNavigate } from 'react-router-dom'; import { useState } from 'react';
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { import {
LayoutDashboard, LayoutDashboard,
CheckSquare, CheckSquare,
@ -10,19 +12,21 @@ import {
Settings, Settings,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronDown,
X, X,
LogOut, LogOut,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import api from '@/lib/api';
import type { Project } from '@/types';
const navItems = [ const navItems = [
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/todos', icon: CheckSquare, label: 'Todos' }, { to: '/todos', icon: CheckSquare, label: 'Todos' },
{ to: '/calendar', icon: Calendar, label: 'Calendar' }, { to: '/calendar', icon: Calendar, label: 'Calendar' },
{ to: '/reminders', icon: Bell, label: 'Reminders' }, { to: '/reminders', icon: Bell, label: 'Reminders' },
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
{ to: '/people', icon: Users, label: 'People' }, { to: '/people', icon: Users, label: 'People' },
{ to: '/locations', icon: MapPin, label: 'Locations' }, { to: '/locations', icon: MapPin, label: 'Locations' },
]; ];
@ -36,13 +40,26 @@ interface SidebarProps {
export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) { export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { logout } = useAuth(); const { logout } = useAuth();
const [projectsExpanded, setProjectsExpanded] = useState(false);
const { data: trackedProjects } = useQuery({
queryKey: ['projects', 'tracked'],
queryFn: async () => {
const { data } = await api.get<Project[]>('/projects?tracked=true');
return data;
},
});
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate('/login'); navigate('/login');
}; };
const isProjectsActive = location.pathname.startsWith('/projects');
const showExpanded = !collapsed || mobileOpen;
const navLinkClass = ({ isActive }: { isActive: boolean }) => const navLinkClass = ({ isActive }: { isActive: boolean }) =>
cn( cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200', 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200',
@ -51,6 +68,80 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
: 'text-muted-foreground hover:bg-accent/10 hover:text-accent border-l-2 border-transparent' : 'text-muted-foreground hover:bg-accent/10 hover:text-accent border-l-2 border-transparent'
); );
const projectsSection = (
<div>
<div
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200 border-l-2',
isProjectsActive
? 'bg-accent/15 text-accent border-accent'
: 'text-muted-foreground hover:bg-accent/10 hover:text-accent border-transparent'
)}
>
<FolderKanban
className="h-5 w-5 shrink-0 cursor-pointer"
onClick={() => {
navigate('/projects');
if (mobileOpen) onMobileClose();
}}
/>
{showExpanded && (
<>
<span
className="flex-1 cursor-pointer"
onClick={() => {
navigate('/projects');
if (mobileOpen) onMobileClose();
}}
>
Projects
</span>
{trackedProjects && trackedProjects.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setProjectsExpanded(!projectsExpanded);
}}
className="p-0.5 rounded hover:bg-accent/10 transition-colors"
>
<ChevronDown
className={cn(
'h-3.5 w-3.5 transition-transform duration-200',
projectsExpanded ? 'rotate-0' : '-rotate-90'
)}
/>
</button>
)}
</>
)}
</div>
{showExpanded && projectsExpanded && trackedProjects && trackedProjects.length > 0 && (
<div className="mt-0.5 space-y-0.5">
{trackedProjects.map((project) => {
const isActive = location.pathname === `/projects/${project.id}`;
return (
<button
key={project.id}
onClick={() => {
navigate(`/projects/${project.id}`);
if (mobileOpen) onMobileClose();
}}
className={cn(
'flex items-center w-full text-left pl-9 pr-3 py-1.5 text-xs rounded-md transition-colors duration-150 truncate',
isActive
? 'text-accent bg-accent/10'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)}
>
<span className="truncate">{project.name}</span>
</button>
);
})}
</div>
)}
</div>
);
const sidebarContent = ( const sidebarContent = (
<> <>
<div className="flex h-16 items-center justify-between border-b px-4"> <div className="flex h-16 items-center justify-between border-b px-4">
@ -80,9 +171,10 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
className={navLinkClass} className={navLinkClass}
> >
<item.icon className="h-5 w-5 shrink-0" /> <item.icon className="h-5 w-5 shrink-0" />
{(!collapsed || mobileOpen) && <span>{item.label}</span>} {showExpanded && <span>{item.label}</span>}
</NavLink> </NavLink>
))} ))}
{projectsSection}
</nav> </nav>
<div className="border-t p-2 space-y-1"> <div className="border-t p-2 space-y-1">
@ -92,14 +184,14 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
className={navLinkClass} className={navLinkClass}
> >
<Settings className="h-5 w-5 shrink-0" /> <Settings className="h-5 w-5 shrink-0" />
{(!collapsed || mobileOpen) && <span>Settings</span>} {showExpanded && <span>Settings</span>}
</NavLink> </NavLink>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
> >
<LogOut className="h-5 w-5 shrink-0" /> <LogOut className="h-5 w-5 shrink-0" />
{(!collapsed || mobileOpen) && <span>Logout</span>} {showExpanded && <span>Logout</span>}
</button> </button>
</div> </div>
</> </>

View File

@ -1,6 +1,9 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, isPast, parseISO } from 'date-fns'; import { format, isPast, parseISO } from 'date-fns';
import { Calendar } from 'lucide-react'; import { Calendar, Pin } from 'lucide-react';
import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -30,6 +33,21 @@ const statusLabels: Record<string, string> = {
export default function ProjectCard({ project }: ProjectCardProps) { export default function ProjectCard({ project }: ProjectCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const toggleTrackMutation = useMutation({
mutationFn: async () => {
const { data } = await api.put(`/projects/${project.id}`, { is_tracked: !project.is_tracked });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
toast.success(project.is_tracked ? 'Project untracked' : 'Project tracked');
},
onError: () => {
toast.error('Failed to update tracking');
},
});
const completedTasks = project.tasks?.filter((t) => t.status === 'completed').length || 0; const completedTasks = project.tasks?.filter((t) => t.status === 'completed').length || 0;
const totalTasks = project.tasks?.length || 0; const totalTasks = project.tasks?.length || 0;
@ -42,11 +60,25 @@ export default function ProjectCard({ project }: ProjectCardProps) {
return ( return (
<Card <Card
className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200" className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200 relative"
onClick={() => navigate(`/projects/${project.id}`)} onClick={() => navigate(`/projects/${project.id}`)}
> >
<button
onClick={(e) => {
e.stopPropagation();
toggleTrackMutation.mutate();
}}
className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${
project.is_tracked
? 'text-accent hover:bg-accent/10'
: 'text-muted-foreground/40 hover:text-muted-foreground hover:bg-card-elevated'
}`}
title={project.is_tracked ? 'Untrack project' : 'Track project'}
>
<Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} />
</button>
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2 pr-6">
<CardTitle className="font-heading text-lg font-semibold">{project.name}</CardTitle> <CardTitle className="font-heading text-lg font-semibold">{project.name}</CardTitle>
<Badge className={statusColors[project.status]}> <Badge className={statusColors[project.status]}>
{statusLabels[project.status]} {statusLabels[project.status]}

View File

@ -20,7 +20,7 @@ import {
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { import {
ArrowLeft, Plus, Trash2, ListChecks, Pencil, ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
Calendar, CheckCircle2, PlayCircle, AlertTriangle, Calendar, CheckCircle2, PlayCircle, AlertTriangle,
List, Columns3, ArrowUpDown, List, Columns3, ArrowUpDown,
} from 'lucide-react'; } from 'lucide-react';
@ -188,6 +188,21 @@ export default function ProjectDetail() {
}, },
}); });
const toggleTrackMutation = useMutation({
mutationFn: async () => {
const { data } = await api.put(`/projects/${id}`, { is_tracked: !project?.is_tracked });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: ['projects', id] });
toast.success(project?.is_tracked ? 'Project untracked' : 'Project tracked');
},
onError: () => {
toast.error('Failed to update tracking');
},
});
const reorderMutation = useMutation({ const reorderMutation = useMutation({
mutationFn: async (items: { id: number; sort_order: number }[]) => { mutationFn: async (items: { id: number; sort_order: number }[]) => {
await api.put(`/projects/${id}/tasks/reorder`, items); await api.put(`/projects/${id}/tasks/reorder`, items);
@ -386,6 +401,16 @@ export default function ProjectDetail() {
<Badge className={statusColors[project.status]}> <Badge className={statusColors[project.status]}>
{statusLabels[project.status]} {statusLabels[project.status]}
</Badge> </Badge>
<Button
variant="ghost"
size="icon"
onClick={() => toggleTrackMutation.mutate()}
disabled={toggleTrackMutation.isPending}
className={project.is_tracked ? 'text-accent' : 'text-muted-foreground'}
title={project.is_tracked ? 'Untrack project' : 'Track project'}
>
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
</Button>
<Button variant="outline" size="sm" onClick={() => setShowProjectForm(true)}> <Button variant="outline" size="sm" onClick={() => setShowProjectForm(true)}>
<Pencil className="mr-2 h-3.5 w-3.5" /> <Pencil className="mr-2 h-3.5 w-3.5" />
Edit Edit

View File

@ -94,11 +94,23 @@ export interface Project {
status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold'; status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold';
color?: string; color?: string;
due_date?: string; due_date?: string;
is_tracked: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
tasks: ProjectTask[]; tasks: ProjectTask[];
} }
export interface TrackedTask {
id: number;
title: string;
status: string;
priority: string;
due_date: string;
project_name: string;
project_id: number;
parent_task_title?: string;
}
export interface TaskComment { export interface TaskComment {
id: number; id: number;
task_id: number; task_id: number;