Kyle Pope c67567e186 Resolve remaining QA suggestions: shared constants, query tuning, cleanup
- Extract duplicate statusColors/statusLabels to projects/constants.ts
- Add staleTime + select to sidebar tracked projects query to reduce
  refetches and narrow data to only id/name
- Gate TrackedProjectsWidget query on settings being loaded
- Remove unnecessary from_attributes on TrackedTaskResponse schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 03:10:48 +08:00

102 lines
3.8 KiB
TypeScript

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, 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';
import { statusColors, statusLabels } from './constants';
interface ProjectCardProps {
project: Project;
onEdit: (project: Project) => void;
}
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'] });
queryClient.invalidateQueries({ queryKey: ['tracked-tasks'] });
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;
const progress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
const isOverdue =
project.due_date &&
project.status !== 'completed' &&
isPast(parseISO(project.due_date));
return (
<Card
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 pr-6">
<CardTitle className="font-heading text-lg font-semibold">{project.name}</CardTitle>
<Badge className={statusColors[project.status]}>
{statusLabels[project.status]}
</Badge>
</div>
<CardDescription className="line-clamp-2">
{project.description || <span className="italic text-muted-foreground/50">No description</span>}
</CardDescription>
</CardHeader>
<CardContent>
{totalTasks > 0 && (
<div className="mb-3">
<div className="flex justify-between text-sm mb-1">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium tabular-nums">
{completedTasks}/{totalTasks} tasks
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{project.due_date && (
<div className={`flex items-center gap-2 text-sm ${isOverdue ? 'text-red-400' : 'text-muted-foreground'}`}>
<Calendar className="h-4 w-4" />
Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
</div>
)}
</CardContent>
</Card>
);
}