mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-10-27 05:29:03 +08:00
feat(web): add submissions data table with filtering, sorting, and pagination
This commit is contained in:
439
web/src/components/submissions-data-table.tsx
Normal file
439
web/src/components/submissions-data-table.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user