270 lines
8.6 KiB
TypeScript
270 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import {
|
|
Activity,
|
|
CheckCircle2,
|
|
PlusCircle,
|
|
MessageSquare,
|
|
UserPlus,
|
|
Edit3,
|
|
FolderKanban,
|
|
Filter,
|
|
RefreshCw,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useActivityFeed, ActivityFilterType } from "@/hooks/use-activity-feed";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import type { ActivityItem } from "@/lib/supabase/database.types";
|
|
|
|
const activityTypeConfig: Record<
|
|
ActivityItem["type"],
|
|
{ label: string; icon: React.ElementType; color: string }
|
|
> = {
|
|
task_created: {
|
|
label: "Created",
|
|
icon: PlusCircle,
|
|
color: "bg-blue-500/10 text-blue-500",
|
|
},
|
|
task_completed: {
|
|
label: "Completed",
|
|
icon: CheckCircle2,
|
|
color: "bg-green-500/10 text-green-500",
|
|
},
|
|
task_updated: {
|
|
label: "Updated",
|
|
icon: Edit3,
|
|
color: "bg-yellow-500/10 text-yellow-500",
|
|
},
|
|
comment_added: {
|
|
label: "Comment",
|
|
icon: MessageSquare,
|
|
color: "bg-purple-500/10 text-purple-500",
|
|
},
|
|
task_assigned: {
|
|
label: "Assigned",
|
|
icon: UserPlus,
|
|
color: "bg-pink-500/10 text-pink-500",
|
|
},
|
|
};
|
|
|
|
const filterOptions: { value: ActivityFilterType; label: string }[] = [
|
|
{ value: "all", label: "All Activity" },
|
|
{ value: "task_created", label: "Created" },
|
|
{ value: "task_completed", label: "Completed" },
|
|
{ value: "task_updated", label: "Updated" },
|
|
{ value: "comment_added", label: "Comments" },
|
|
{ value: "task_assigned", label: "Assigned" },
|
|
];
|
|
|
|
function ActivityItemCard({ activity }: { activity: ActivityItem }) {
|
|
const config = activityTypeConfig[activity.type];
|
|
const Icon = config.icon;
|
|
const timeAgo = formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true });
|
|
|
|
return (
|
|
<div className="flex gap-4 pb-6 border-b border-border last:border-0 last:pb-0">
|
|
{/* Icon */}
|
|
<div
|
|
className={cn(
|
|
"w-10 h-10 rounded-full flex items-center justify-center shrink-0",
|
|
config.color
|
|
)}
|
|
>
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-medium text-sm">{activity.user_name}</span>
|
|
<Badge variant="secondary" className="text-xs font-normal">
|
|
{config.label}
|
|
</Badge>
|
|
<span className="text-muted-foreground text-sm truncate">
|
|
{activity.task_title}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Project badge */}
|
|
<div className="flex items-center gap-2">
|
|
<FolderKanban className="w-3 h-3 text-muted-foreground" />
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs"
|
|
style={{
|
|
borderColor: activity.project_color,
|
|
color: activity.project_color,
|
|
}}
|
|
>
|
|
{activity.project_name}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Details */}
|
|
{activity.details && (
|
|
<p className="text-sm text-muted-foreground">{activity.details}</p>
|
|
)}
|
|
|
|
{/* Comment text */}
|
|
{activity.comment_text && (
|
|
<div className="mt-2 p-3 bg-muted rounded-md">
|
|
<p className="text-sm text-muted-foreground line-clamp-3">
|
|
"{activity.comment_text}"
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Timestamp */}
|
|
<p className="text-xs text-muted-foreground">{timeAgo}</p>
|
|
</div>
|
|
|
|
{/* Avatar */}
|
|
{activity.user_avatar_url ? (
|
|
<img
|
|
src={activity.user_avatar_url}
|
|
alt={activity.user_name}
|
|
className="w-8 h-8 rounded-full shrink-0"
|
|
/>
|
|
) : (
|
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 shrink-0" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActivitySkeleton() {
|
|
return (
|
|
<div className="flex gap-4 pb-6 border-b border-border">
|
|
<Skeleton className="w-10 h-10 rounded-full shrink-0" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-4 w-16" />
|
|
</div>
|
|
<Skeleton className="h-3 w-32" />
|
|
<Skeleton className="h-3 w-20" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ActivityFeed() {
|
|
const [filterType, setFilterType] = useState<ActivityFilterType>("all");
|
|
const [projectId, setProjectId] = useState<string>("all");
|
|
|
|
const { activities, projects, loading, error, refresh } = useActivityFeed({
|
|
limit: 50,
|
|
projectId: projectId === "all" ? undefined : projectId,
|
|
filterType: filterType === "all" ? undefined : filterType,
|
|
});
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<CardTitle className="flex items-center gap-2 text-lg">
|
|
<Activity className="w-5 h-5" />
|
|
Recent Activity
|
|
</CardTitle>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{/* Filter by type */}
|
|
<Select value={filterType} onValueChange={(v) => setFilterType(v as ActivityFilterType)}>
|
|
<SelectTrigger className="w-[140px] h-9">
|
|
<Filter className="w-4 h-4 mr-2" />
|
|
<SelectValue placeholder="Filter type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{filterOptions.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Filter by project */}
|
|
<Select value={projectId} onValueChange={setProjectId}>
|
|
<SelectTrigger className="w-[160px] h-9">
|
|
<FolderKanban className="w-4 h-4 mr-2" />
|
|
<SelectValue placeholder="All projects" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All projects</SelectItem>
|
|
{projects.map((project) => (
|
|
<SelectItem key={project.id} value={project.id}>
|
|
<span
|
|
className="inline-block w-2 h-2 rounded-full mr-2"
|
|
style={{ backgroundColor: project.color }}
|
|
/>
|
|
{project.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Refresh button */}
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-9 w-9"
|
|
onClick={refresh}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ScrollArea className="h-[600px] pr-4">
|
|
<div className="space-y-6">
|
|
{loading ? (
|
|
// Loading skeletons
|
|
Array.from({ length: 5 }).map((_, i) => (
|
|
<ActivitySkeleton key={i} />
|
|
))
|
|
) : error ? (
|
|
// Error state
|
|
<div className="text-center py-12">
|
|
<p className="text-muted-foreground">Failed to load activities</p>
|
|
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
|
<Button variant="outline" className="mt-4" onClick={refresh}>
|
|
Try again
|
|
</Button>
|
|
</div>
|
|
) : activities.length === 0 ? (
|
|
// Empty state
|
|
<div className="text-center py-12">
|
|
<Activity className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
|
<p className="text-muted-foreground">No activity yet</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Tasks and comments will appear here
|
|
</p>
|
|
</div>
|
|
) : (
|
|
// Activity list
|
|
activities.map((activity) => (
|
|
<ActivityItemCard key={activity.id} activity={activity} />
|
|
))
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|