Files
starr-philproperties/src/modules/admin/pages/activity/ActivityFeed.jsx
T
kennethobsequio b1805cfe4d try to deploy
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-13 21:27:56 +08:00

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