Kyle Pope f42175b3fe Improve sharing visibility: member count on cards, task assignment toast
- 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>
2026-03-17 04:09:07 +08:00

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>
);
}