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 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())
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
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 {
|
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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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]}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user