From fb99a7ff9ab99095715a715cfe8e2d70efe37032 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 1 Oct 2025 15:47:25 +0200 Subject: [PATCH] feat(web): add submissions data table with filtering, sorting, and pagination --- web/src/components/submissions-data-table.tsx | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 web/src/components/submissions-data-table.tsx diff --git a/web/src/components/submissions-data-table.tsx b/web/src/components/submissions-data-table.tsx new file mode 100644 index 00000000..edb0fe40 --- /dev/null +++ b/web/src/components/submissions-data-table.tsx @@ -0,0 +1,439 @@ +"use client" + +import * as React from "react" +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getSortedRowModel, + type SortingState, + type ExpandedState, + getExpandedRowModel, + getFilteredRowModel, + type ColumnFiltersState, +} from "@tanstack/react-table" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { ChevronDown, ChevronRight, ImageIcon, Check, X, Search, Filter, SortDesc } from "lucide-react" +import { SubmissionDetails } from "@/components/submission-details" +import { cn } from "@/lib/utils" +import { pb, type Submission } from "@/lib/pb" +import dayjs from "dayjs" +import relativeTime from "dayjs/plugin/relativeTime" +import { UserDisplay } from "@/components/user-display" + +// Initialize dayjs relative time plugin +dayjs.extend(relativeTime) + +// Utility function to get display name with priority: username > email > created_by field +const getDisplayName = (submission: Submission, expandedData?: any): string => { + console.log('🏷️ Getting display name for submission:', submission.id) + console.log('👤 created_by field:', submission.created_by) + console.log('🔗 expanded data:', expandedData) + + // Check if we have expanded user data + if (expandedData && expandedData.created_by) { + const user = expandedData.created_by + console.log('📋 User data from expand:', user) + + // Priority: username > email + if (user.username) { + console.log('✅ Using username:', user.username) + return user.username + } + if (user.email) { + console.log('✅ Using email:', user.email) + return user.email + } + } + + // Fallback to created_by field (could be user ID or username) + console.log('⚠️ Fallback to created_by field:', submission.created_by) + return submission.created_by +} + +interface SubmissionsDataTableProps { + data: Submission[] + isAdmin: boolean + currentUserId: string + onApprove: (id: string) => void + onReject: (id: string) => void + isApproving?: boolean + isRejecting?: boolean +} + +// Group submissions by status with priority order +const groupAndSortSubmissions = (submissions: Submission[]): Submission[] => { + const statusPriority = { pending: 0, approved: 1, rejected: 2 } + + return [...submissions].sort((a, b) => { + // First, sort by status priority + const statusDiff = statusPriority[a.status] - statusPriority[b.status] + if (statusDiff !== 0) return statusDiff + + // Within same status, sort by updated time (most recent first) + return new Date(b.updated).getTime() - new Date(a.updated).getTime() + }) +} + +const getStatusColor = (status: Submission["status"]) => { + switch (status) { + case "approved": + return "bg-green-500/10 text-green-500 border-green-500/20" + case "rejected": + return "bg-red-500/10 text-red-500 border-red-500/20" + case "pending": + return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" + default: + return "bg-gray-500/10 text-gray-500 border-gray-500/20" + } +} + +const getStatusDisplayName = (status: Submission["status"]) => { + switch (status) { + case "pending": + return "Pending" + case "approved": + return "Approved" + case "rejected": + return "Rejected" + default: + return status + } +} + +export function SubmissionsDataTable({ data, isAdmin, currentUserId, onApprove, onReject, isApproving, isRejecting }: SubmissionsDataTableProps) { + const [sorting, setSorting] = React.useState([]) + const [expanded, setExpanded] = React.useState({}) + const [columnFilters, setColumnFilters] = React.useState([]) + const [globalFilter, setGlobalFilter] = React.useState("") + const [userFilter, setUserFilter] = React.useState<{ userId: string; displayName: string } | null>(null) + + // Handle row expansion - only one row can be expanded at a time + const handleRowToggle = React.useCallback((rowId: string, isExpanded: boolean) => { + setExpanded(isExpanded ? {} : { [rowId]: true }) + }, []) + + // Group and sort data by status and updated time + const groupedData = React.useMemo(() => { + return groupAndSortSubmissions(data) + }, [data]) + + // Handle user filter - filter by user ID but display username + const handleUserFilter = React.useCallback((userId: string, displayName: string) => { + if (userFilter?.userId === userId) { + setUserFilter(null) + setColumnFilters(prev => prev.filter(filter => filter.id !== "created_by")) + } else { + setUserFilter({ userId, displayName }) + setColumnFilters(prev => [ + ...prev.filter(filter => filter.id !== "created_by"), + { id: "created_by", value: userId } + ]) + } + }, [userFilter]) + + const columns: ColumnDef[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => { + return ( + + ) + }, + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) =>
{row.getValue("name")}
, + }, + { + accessorKey: "status", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const status = row.getValue("status") as Submission["status"] + return ( + + {getStatusDisplayName(status)} + + ) + }, + }, + { + accessorKey: "created_by", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const submission = row.original + const expandedData = (submission as any).expand + const displayName = getDisplayName(submission, expandedData) + const userId = submission.created_by + + return ( +
+ + {userFilter?.userId === userId && ( + + )} +
+ ) + }, + }, + { + accessorKey: "updated", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const date = row.getValue("updated") as string + return ( +
+ {dayjs(date).fromNow()} +
+ ) + }, + }, + { + accessorKey: "assets", + header: "Preview", + cell: ({ row }) => { + const assets = row.getValue("assets") as string[] + const name = row.getValue("name") as string + if (assets.length > 0) { + return ( +
+ {name} +
+ ) + } + return ( +
+ +
+ ) + }, + }, + ] + + const table = useReactTable({ + data: groupedData, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onExpandedChange: setExpanded, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + state: { + sorting, + expanded, + columnFilters, + globalFilter, + }, + getRowCanExpand: () => true, + globalFilterFn: (row, columnId, value) => { + const searchValue = value.toLowerCase() + const name = row.getValue("name") as string + const status = row.getValue("status") as string + const submission = row.original + const expandedData = (submission as any).expand + const displayName = getDisplayName(submission, expandedData) + + return ( + name.toLowerCase().includes(searchValue) || + status.toLowerCase().includes(searchValue) || + displayName.toLowerCase().includes(searchValue) + ) + }, + }) + + return ( +
+ {/* Search and Filters */} +
+
+ + setGlobalFilter(String(event.target.value))} + className="pl-10" + /> +
+ + {userFilter && ( +
+ + + User: {userFilter.displayName} + + +
+ )} +
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + (() => { + let lastStatus: string | null = null + return table.getRowModel().rows.map((row, index) => { + const currentStatus = row.original.status + const showStatusHeader = currentStatus !== lastStatus + lastStatus = currentStatus + + return ( + + {showStatusHeader && ( + + +
+ + {getStatusDisplayName(currentStatus)} + + + {table.getRowModel().rows.filter(r => r.original.status === currentStatus).length} + {table.getRowModel().rows.filter(r => r.original.status === currentStatus).length === 1 ? ' submission' : ' submissions'} + +
+
+
+ )} + handleRowToggle(row.id, row.getIsExpanded())} + > + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + {row.getIsExpanded() && ( + + + onApprove(row.original.id) : undefined} + onReject={row.original.status === "pending" && isAdmin ? () => onReject(row.original.id) : undefined} + isApproving={isApproving} + isRejecting={isRejecting} + /> + + + )} +
+ ) + }) + })() + ) : ( + + + {globalFilter || userFilter ? "No submissions found matching your search" : "No submissions found"} + + + )} +
+
+
+
+ ) +}