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:
parent
7ae19a7cfe
commit
819a4689b8
24
backend/alembic/versions/012_add_project_is_tracked.py
Normal file
24
backend/alembic/versions/012_add_project_is_tracked.py
Normal 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")
|
||||
@ -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 datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
@ -14,6 +14,7 @@ class Project(Base):
|
||||
status: Mapped[str] = mapped_column(String(20), default="not_started")
|
||||
color: Mapped[Optional[str]] = mapped_column(String(20), 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())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@ -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 import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from datetime import date, timedelta
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
from app.models.project_task import ProjectTask
|
||||
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.task_comment import TaskCommentCreate, TaskCommentResponse
|
||||
from app.routers.auth import get_current_session
|
||||
@ -43,17 +44,64 @@ def _task_load_options():
|
||||
|
||||
@router.get("/", response_model=List[ProjectResponse])
|
||||
async def get_projects(
|
||||
tracked: Optional[bool] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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())
|
||||
if tracked is not None:
|
||||
query = query.where(Project.is_tracked == tracked)
|
||||
result = await db.execute(query)
|
||||
projects = result.scalars().unique().all()
|
||||
|
||||
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)
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
|
||||
@ -12,6 +12,7 @@ class ProjectCreate(BaseModel):
|
||||
status: ProjectStatus = "not_started"
|
||||
color: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
is_tracked: bool = False
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
@ -20,6 +21,7 @@ class ProjectUpdate(BaseModel):
|
||||
status: Optional[ProjectStatus] = None
|
||||
color: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
is_tracked: Optional[bool] = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
@ -29,8 +31,22 @@ class ProjectResponse(BaseModel):
|
||||
status: str
|
||||
color: Optional[str]
|
||||
due_date: Optional[date]
|
||||
is_tracked: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tasks: List[ProjectTaskResponse] = []
|
||||
|
||||
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)
|
||||
|
||||
@ -12,6 +12,7 @@ import UpcomingWidget from './UpcomingWidget';
|
||||
import WeekTimeline from './WeekTimeline';
|
||||
import DayBriefing from './DayBriefing';
|
||||
import CountdownWidget from './CountdownWidget';
|
||||
import TrackedProjectsWidget from './TrackedProjectsWidget';
|
||||
import EventForm from '../calendar/EventForm';
|
||||
import TodoForm from '../todos/TodoForm';
|
||||
import ReminderForm from '../reminders/ReminderForm';
|
||||
@ -236,6 +237,11 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tracked Projects */}
|
||||
<div className="animate-slide-up" style={{ animationDelay: '200ms', animationFillMode: 'backwards' }}>
|
||||
<TrackedProjectsWidget />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
109
frontend/src/components/dashboard/TrackedProjectsWidget.tsx
Normal file
109
frontend/src/components/dashboard/TrackedProjectsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
LayoutDashboard,
|
||||
CheckSquare,
|
||||
@ -10,19 +12,21 @@ import {
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
X,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import api from '@/lib/api';
|
||||
import type { Project } from '@/types';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/todos', icon: CheckSquare, label: 'Todos' },
|
||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||
{ to: '/reminders', icon: Bell, label: 'Reminders' },
|
||||
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
||||
{ to: '/people', icon: Users, label: 'People' },
|
||||
{ to: '/locations', icon: MapPin, label: 'Locations' },
|
||||
];
|
||||
@ -36,13 +40,26 @@ interface SidebarProps {
|
||||
|
||||
export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
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 () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const isProjectsActive = location.pathname.startsWith('/projects');
|
||||
const showExpanded = !collapsed || mobileOpen;
|
||||
|
||||
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
cn(
|
||||
'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'
|
||||
);
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<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}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
{(!collapsed || mobileOpen) && <span>{item.label}</span>}
|
||||
{showExpanded && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
{projectsSection}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-2 space-y-1">
|
||||
@ -92,14 +184,14 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
||||
className={navLinkClass}
|
||||
>
|
||||
<Settings className="h-5 w-5 shrink-0" />
|
||||
{(!collapsed || mobileOpen) && <span>Settings</span>}
|
||||
{showExpanded && <span>Settings</span>}
|
||||
</NavLink>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<LogOut className="h-5 w-5 shrink-0" />
|
||||
{(!collapsed || mobileOpen) && <span>Logout</span>}
|
||||
{showExpanded && <span>Logout</span>}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
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 { Calendar } from 'lucide-react';
|
||||
import { Calendar, Pin } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import type { Project } from '@/types';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -30,6 +33,21 @@ const statusLabels: Record<string, string> = {
|
||||
|
||||
export default function ProjectCard({ project }: ProjectCardProps) {
|
||||
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 totalTasks = project.tasks?.length || 0;
|
||||
@ -42,11 +60,25 @@ export default function ProjectCard({ project }: ProjectCardProps) {
|
||||
|
||||
return (
|
||||
<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}`)}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<Badge className={statusColors[project.status]}>
|
||||
{statusLabels[project.status]}
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
ArrowLeft, Plus, Trash2, ListChecks, Pencil,
|
||||
ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
|
||||
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
|
||||
List, Columns3, ArrowUpDown,
|
||||
} 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({
|
||||
mutationFn: async (items: { id: number; sort_order: number }[]) => {
|
||||
await api.put(`/projects/${id}/tasks/reorder`, items);
|
||||
@ -386,6 +401,16 @@ export default function ProjectDetail() {
|
||||
<Badge className={statusColors[project.status]}>
|
||||
{statusLabels[project.status]}
|
||||
</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)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
|
||||
@ -94,11 +94,23 @@ export interface Project {
|
||||
status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold';
|
||||
color?: string;
|
||||
due_date?: string;
|
||||
is_tracked: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
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 {
|
||||
id: number;
|
||||
task_id: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user