feat(web): add submissions data table with filtering, sorting, and pagination

This commit is contained in:
Thomas Camlong
2025-10-01 15:47:25 +02:00
parent be90e727c1
commit fb99a7ff9a

View File

@@ -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<SortingState>([])
const [expanded, setExpanded] = React.useState<ExpandedState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
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<Submission>[] = [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return (
<button
onClick={(e) => {
e.stopPropagation()
handleRowToggle(row.id, row.getIsExpanded())
}}
className="flex items-center justify-center w-8 h-8 hover:bg-muted rounded transition-colors"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
)
},
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Name
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => <div className="font-medium capitalize">{row.getValue("name")}</div>,
},
{
accessorKey: "status",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Status
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const status = row.getValue("status") as Submission["status"]
return (
<Badge variant="outline" className={getStatusColor(status)}>
{getStatusDisplayName(status)}
</Badge>
)
},
},
{
accessorKey: "created_by",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Submitted By
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const submission = row.original
const expandedData = (submission as any).expand
const displayName = getDisplayName(submission, expandedData)
const userId = submission.created_by
return (
<div className="flex items-center gap-1">
<UserDisplay
userId={userId}
avatar={expandedData.created_by.avatar}
displayName={displayName}
onClick={handleUserFilter}
size="md"
/>
{userFilter?.userId === userId && (
<X className="h-3 w-3 text-muted-foreground" />
)}
</div>
)
},
},
{
accessorKey: "updated",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Updated
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const date = row.getValue("updated") as string
return (
<div className="text-sm text-muted-foreground" title={dayjs(date).format("MMMM D, YYYY h:mm A")}>
{dayjs(date).fromNow()}
</div>
)
},
},
{
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 (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-background p-2">
<img
src={`${pb.baseUrl}/api/files/submissions/${row.original.id}/${assets[0]}?thumb=100x100` || "/placeholder.svg"}
alt={name}
className="w-full h-full object-contain"
/>
</div>
)
}
return (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-muted">
<ImageIcon className="w-6 h-6 text-muted-foreground" />
</div>
)
},
},
]
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 (
<div className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search submissions..."
value={globalFilter ?? ""}
onChange={(event) => setGlobalFilter(String(event.target.value))}
className="pl-10"
/>
</div>
{userFilter && (
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<Badge variant="secondary" className="gap-1">
User: {userFilter.displayName}
<Button
variant="ghost"
size="sm"
className="h-auto p-0 hover:bg-transparent"
onClick={() => {
setUserFilter(null)
setColumnFilters(prev => prev.filter(filter => filter.id !== "created_by"))
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
</div>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{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 (
<React.Fragment key={row.id}>
{showStatusHeader && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={columns.length} className="py-2 font-semibold text-sm">
<div className="flex items-center gap-2">
<Badge variant="outline" className={getStatusColor(currentStatus)}>
{getStatusDisplayName(currentStatus)}
</Badge>
<span className="text-xs text-muted-foreground">
{table.getRowModel().rows.filter(r => r.original.status === currentStatus).length}
{table.getRowModel().rows.filter(r => r.original.status === currentStatus).length === 1 ? ' submission' : ' submissions'}
</span>
</div>
</TableCell>
</TableRow>
)}
<TableRow
data-state={row.getIsSelected() && "selected"}
className={cn(
"cursor-pointer hover:bg-muted/50 transition-colors",
row.getIsExpanded() && "bg-muted/30"
)}
onClick={() => handleRowToggle(row.id, row.getIsExpanded())}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={columns.length} className="p-6 bg-muted/20 border-t">
<SubmissionDetails
submission={row.original}
isAdmin={isAdmin}
onUserClick={handleUserFilter}
onApprove={row.original.status === "pending" && isAdmin ? () => onApprove(row.original.id) : undefined}
onReject={row.original.status === "pending" && isAdmin ? () => onReject(row.original.id) : undefined}
isApproving={isApproving}
isRejecting={isRejecting}
/>
</TableCell>
</TableRow>
)}
</React.Fragment>
)
})
})()
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{globalFilter || userFilter ? "No submissions found matching your search" : "No submissions found"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)
}