mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-10-26 21:19:04 +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