mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
267 lines
12 KiB
React
267 lines
12 KiB
React
import { useEffect, useState, useCallback } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useUsers } from "@/contexts/AdminUserContext";
|
|
import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink } from "lucide-react";
|
|
|
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
|
import { DatePickerButton } from "@/components/generic/DatePickerButton";
|
|
|
|
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
|
import { timeAgo } from "@/utils/timestamp.util";
|
|
import { fmtISO } from "@/utils/datetime.util";
|
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
|
|
|
const BREADCRUMB = [
|
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
{ label: "Activity Feed" },
|
|
];
|
|
|
|
const LIMIT = 20;
|
|
|
|
function toDateStr(d) {
|
|
if (!d) return undefined;
|
|
return fmtISO(d);
|
|
}
|
|
|
|
export default function ActivityFeed() {
|
|
const navigate = useNavigate();
|
|
const { fetchActivity, activity, activityPagination, loading } = useUsers();
|
|
|
|
const [page, setPage] = useState(1);
|
|
const [action, setAction] = useState("all");
|
|
const [from, setFrom] = useState(null);
|
|
const [to, setTo] = useState(null);
|
|
|
|
const load = useCallback(
|
|
(p = 1) => {
|
|
fetchActivity({
|
|
page: p,
|
|
limit: LIMIT,
|
|
action: action === "all" ? undefined : action,
|
|
from: toDateStr(from),
|
|
to: toDateStr(to),
|
|
});
|
|
setPage(p);
|
|
},
|
|
[fetchActivity, action, from, to]
|
|
);
|
|
|
|
useEffect(() => { load(1); }, [action, from, to]);
|
|
|
|
return (
|
|
<section className="bg-muted/60 min-h-full">
|
|
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 pb-10">
|
|
|
|
{/* ─── Header ────────────────────────────────────────────────────── */}
|
|
<div className="flex flex-col gap-2 my-6">
|
|
<AppBreadcrumb items={BREADCRUMB} />
|
|
</div>
|
|
|
|
<div className="w-full flex flex-col gap-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div>
|
|
<h1 className="text-xl font-semibold tracking-tight">Activity Feed</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
All user actions across the system — {activityPagination.totalRecords} total
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => load(page)} disabled={loading}>
|
|
<RefreshCw className={`size-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* ─── Filters ───────────────────────────────────────────────── */}
|
|
<div className="flex flex-wrap gap-3 bg-card border rounded-lg p-4">
|
|
<div className="flex flex-col gap-1 min-w-[180px]">
|
|
<span className="text-xs text-muted-foreground">Action</span>
|
|
<Select value={action} onValueChange={setAction}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue placeholder="All actions" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All actions</SelectItem>
|
|
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
|
|
<SelectItem key={key} value={key}>{cfg.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs text-muted-foreground">From</span>
|
|
<DatePickerButton value={from} onChange={setFrom} placeholder="Start date" />
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs text-muted-foreground">To</span>
|
|
<DatePickerButton value={to} onChange={setTo} placeholder="End date" disabled={from ? { before: from } : undefined} />
|
|
</div>
|
|
|
|
{(action !== "all" || from || to) && (
|
|
<div className="flex items-end">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 text-xs"
|
|
onClick={() => { setAction("all"); setFrom(null); setTo(null); }}
|
|
>
|
|
Clear filters
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ─── Table ─────────────────────────────────────────────────── */}
|
|
<div className="bg-card border rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b bg-muted/40">
|
|
<tr>
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">User</th>
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Role</th>
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Action</th>
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Entity</th>
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Time</th>
|
|
<th className="px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{loading ? (
|
|
[...Array(8)].map((_, i) => (
|
|
<tr key={i}>
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-36" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-16" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-5 w-24 rounded-full" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
|
|
<td className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
|
|
<td className="px-4 py-3" />
|
|
</tr>
|
|
))
|
|
) : activity.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground text-sm">
|
|
No activity found.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
activity.map((row) => (
|
|
<ActivityRow
|
|
key={row.activity_id}
|
|
row={row}
|
|
onViewUser={() => navigate(`/admin/users/view/${row.user_id}`)}
|
|
/>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* ─── Pagination ────────────────────────────────────────── */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-t bg-muted/20 text-sm text-muted-foreground">
|
|
<span>
|
|
{activityPagination.totalRecords > 0
|
|
? `Page ${activityPagination.page} of ${activityPagination.totalPages} · ${activityPagination.totalRecords} total`
|
|
: "No results"}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline" size="icon" className="h-7 w-7"
|
|
disabled={!activityPagination.hasPrevPage || loading}
|
|
onClick={() => load(activityPagination.page - 1)}
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline" size="icon" className="h-7 w-7"
|
|
disabled={!activityPagination.hasNextPage || loading}
|
|
onClick={() => load(activityPagination.page + 1)}
|
|
>
|
|
<ChevronRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ─── Row ──────────────────────────────────────────────────────────────────────
|
|
function initials(name, email) {
|
|
if (name) return name.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase();
|
|
return (email?.[0] ?? "?").toUpperCase();
|
|
}
|
|
|
|
function ActivityRow({ row, onViewUser }) {
|
|
const { fmtDateTime } = useDateFormat();
|
|
const { label, className } = getActionBadge(row.action);
|
|
const ts = row.created_at;
|
|
const displayName = row.full_name ?? row.email ?? `User #${row.user_id}`;
|
|
|
|
return (
|
|
<tr className="hover:bg-muted/30 transition-colors">
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar size="sm" className="shrink-0">
|
|
<AvatarImage src={row.avatar_url ?? undefined} alt={row.full_name ?? row.email} />
|
|
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
|
|
{initials(row.full_name, row.email)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex flex-col min-w-0">
|
|
<span className="font-medium text-sm truncate max-w-[200px]">{displayName}</span>
|
|
{row.full_name && (
|
|
<span className="text-xs text-muted-foreground truncate max-w-[200px]">{row.email}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant="outline" className="capitalize text-xs">{row.acc_type ?? "—"}</Badge>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${className}`}>
|
|
{label}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground text-xs">
|
|
{row.entity_type
|
|
? <span className="capitalize">{row.entity_type}{row.entity_id ? ` #${row.entity_id}` : ""}</span>
|
|
: <span className="text-muted-foreground/50">—</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs whitespace-nowrap">
|
|
{ts ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="text-muted-foreground cursor-default">{fmtDateTime(ts)}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left">
|
|
{timeAgo(ts)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<span className="text-muted-foreground/50">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="outline" size="sm" className="opacity-50 hover:opacity-100" onClick={onViewUser}>
|
|
<ExternalLink />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>View user</TooltipContent>
|
|
</Tooltip>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|