- Add member_count to ProjectResponse via model_validator (computed from
eagerly loaded members relationship). Shows on ProjectCard for both
owners ("2 members") and shared users ("Shared with you").
- Fix share button badge positioning (add relative class).
- Add dedicated showTaskAssignedToast with blue ClipboardList icon,
"View Project" action button, and 15s duration.
- Wire task_assigned into both initial-load and new-notification toast
dispatch flows in NotificationToaster.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
118 lines
4.5 KiB
TypeScript
118 lines
4.5 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, Users } from 'lucide-react';
|
|
import api from '@/lib/api';
|
|
import type { Project } from '@/types';
|
|
import { useSettings } from '@/hooks/useSettings';
|
|
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 { settings } = useSettings();
|
|
const isShared = project.user_id !== (settings?.user_id ?? 0);
|
|
|
|
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}`)}
|
|
>
|
|
{!isShared && (
|
|
<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>
|
|
)}
|
|
{/* Sharing indicator — shows for both owner and shared users */}
|
|
{project.member_count > 0 && (
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-2">
|
|
<Users className="h-3.5 w-3.5" />
|
|
<span>
|
|
{isShared
|
|
? 'Shared with you'
|
|
: `${project.member_count} member${project.member_count !== 1 ? 's' : ''}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|