Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
153
src/components/charts/cross-round-comparison.tsx
Normal file
153
src/components/charts/cross-round-comparison.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface RoundComparison {
|
||||
roundId: string
|
||||
roundName: string
|
||||
projectCount: number
|
||||
evaluationCount: number
|
||||
completionRate: number
|
||||
averageScore: number | null
|
||||
scoreDistribution: { score: number; count: number }[]
|
||||
}
|
||||
|
||||
interface CrossRoundComparisonProps {
|
||||
data: RoundComparison[]
|
||||
}
|
||||
|
||||
const ROUND_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f']
|
||||
|
||||
export function CrossRoundComparisonChart({ data }: CrossRoundComparisonProps) {
|
||||
// Prepare comparison data
|
||||
const comparisonData = data.map((round, i) => ({
|
||||
name: round.roundName.length > 20 ? round.roundName.slice(0, 20) + '...' : round.roundName,
|
||||
projects: round.projectCount,
|
||||
evaluations: round.evaluationCount,
|
||||
completionRate: round.completionRate,
|
||||
avgScore: round.averageScore ? parseFloat(round.averageScore.toFixed(2)) : 0,
|
||||
color: ROUND_COLORS[i % ROUND_COLORS.length],
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Metrics Comparison */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Round Metrics Comparison</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={comparisonData}
|
||||
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar dataKey="projects" name="Projects" fill="#053d57" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="evaluations" name="Evaluations" fill="#557f8c" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Completion & Score Comparison */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Completion Rate by Round</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={comparisonData}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis domain={[0, 100]} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="completionRate" name="Completion %" fill="#6ad82f" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Average Score by Round</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={comparisonData}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis domain={[0, 10]} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
230
src/components/charts/diversity-metrics.tsx
Normal file
230
src/components/charts/diversity-metrics.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface DiversityData {
|
||||
total: number
|
||||
byCountry: { country: string; count: number; percentage: number }[]
|
||||
byCategory: { category: string; count: number; percentage: number }[]
|
||||
byOceanIssue: { issue: string; count: number; percentage: number }[]
|
||||
byTag: { tag: string; count: number; percentage: number }[]
|
||||
}
|
||||
|
||||
interface DiversityMetricsProps {
|
||||
data: DiversityData
|
||||
}
|
||||
|
||||
const PIE_COLORS = [
|
||||
'#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f',
|
||||
'#3be31e', '#c9c052', '#e6382f', '#ed6141', '#0bd90f',
|
||||
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1',
|
||||
]
|
||||
|
||||
export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
if (data.total === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No project data available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Top countries for pie chart (max 10, others grouped)
|
||||
const topCountries = data.byCountry.slice(0, 10)
|
||||
const otherCountries = data.byCountry.slice(10)
|
||||
const countryPieData = otherCountries.length > 0
|
||||
? [...topCountries, {
|
||||
country: 'Others',
|
||||
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
|
||||
percentage: otherCountries.reduce((sum, c) => sum + c.percentage, 0),
|
||||
}]
|
||||
: topCountries
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">{data.total}</div>
|
||||
<p className="text-sm text-muted-foreground">Total Projects</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">{data.byCountry.length}</div>
|
||||
<p className="text-sm text-muted-foreground">Countries Represented</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">{data.byCategory.length}</div>
|
||||
<p className="text-sm text-muted-foreground">Categories</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">{data.byTag.length}</div>
|
||||
<p className="text-sm text-muted-foreground">Unique Tags</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Country Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geographic Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={countryPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={120}
|
||||
paddingAngle={2}
|
||||
dataKey="count"
|
||||
nameKey="country"
|
||||
label
|
||||
>
|
||||
{countryPieData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Competition Categories</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.byCategory.length > 0 ? (
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data.byCategory.slice(0, 10)}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, bottom: 5, left: 100 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category"
|
||||
width={90}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">No category data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Ocean Issues */}
|
||||
{data.byOceanIssue.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ocean Issues Addressed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data.byOceanIssue.slice(0, 15)}
|
||||
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="issue"
|
||||
angle={-35}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags Cloud */}
|
||||
{data.byTag.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.byTag.slice(0, 30).map((tag) => (
|
||||
<Badge
|
||||
key={tag.tag}
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
style={{
|
||||
fontSize: `${Math.max(0.7, Math.min(1.4, 0.7 + tag.percentage / 20))}rem`,
|
||||
}}
|
||||
>
|
||||
{tag.tag} ({tag.count})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,3 +6,7 @@ export { ProjectRankingsChart } from './project-rankings'
|
||||
export { CriteriaScoresChart } from './criteria-scores'
|
||||
export { GeographicDistribution } from './geographic-distribution'
|
||||
export { GeographicSummaryCard } from './geographic-summary-card'
|
||||
// Advanced analytics charts (F10)
|
||||
export { CrossRoundComparisonChart } from './cross-round-comparison'
|
||||
export { JurorConsistencyChart } from './juror-consistency'
|
||||
export { DiversityMetricsChart } from './diversity-metrics'
|
||||
|
||||
171
src/components/charts/juror-consistency.tsx
Normal file
171
src/components/charts/juror-consistency.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface JurorMetric {
|
||||
userId: string
|
||||
name: string
|
||||
email: string
|
||||
evaluationCount: number
|
||||
averageScore: number
|
||||
stddev: number
|
||||
deviationFromOverall: number
|
||||
isOutlier: boolean
|
||||
}
|
||||
|
||||
interface JurorConsistencyProps {
|
||||
data: {
|
||||
overallAverage: number
|
||||
jurors: JurorMetric[]
|
||||
}
|
||||
}
|
||||
|
||||
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
const scatterData = data.jurors.map((j) => ({
|
||||
name: j.name,
|
||||
avgScore: parseFloat(j.averageScore.toFixed(2)),
|
||||
stddev: parseFloat(j.stddev.toFixed(2)),
|
||||
evaluations: j.evaluationCount,
|
||||
isOutlier: j.isOutlier,
|
||||
}))
|
||||
|
||||
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Scatter: Average Score vs Standard Deviation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Juror Scoring Patterns</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Overall Avg: {data.overallAverage.toFixed(2)}
|
||||
{outlierCount > 0 && (
|
||||
<Badge variant="destructive" className="ml-2">
|
||||
{outlierCount} outlier{outlierCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="avgScore"
|
||||
name="Average Score"
|
||||
domain={[0, 10]}
|
||||
label={{ value: 'Average Score', position: 'insideBottom', offset: -10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="stddev"
|
||||
name="Std Deviation"
|
||||
label={{ value: 'Std Deviation', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={data.overallAverage}
|
||||
stroke="#de0f1e"
|
||||
strokeDasharray="3 3"
|
||||
label={{ value: 'Avg', fill: '#de0f1e', position: 'top' }}
|
||||
/>
|
||||
<Scatter data={scatterData} fill="#053d57">
|
||||
{scatterData.map((entry, index) => (
|
||||
<circle
|
||||
key={index}
|
||||
r={Math.max(4, entry.evaluations)}
|
||||
fill={entry.isOutlier ? '#de0f1e' : '#053d57'}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Juror details table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Juror Consistency Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Juror</TableHead>
|
||||
<TableHead className="text-right">Evaluations</TableHead>
|
||||
<TableHead className="text-right">Avg Score</TableHead>
|
||||
<TableHead className="text-right">Std Dev</TableHead>
|
||||
<TableHead className="text-right">Deviation from Mean</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.jurors.map((juror) => (
|
||||
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{juror.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{juror.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{juror.evaluationCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{juror.averageScore.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{juror.stddev.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.deviationFromOverall.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{juror.isOutlier ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Outlier
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Normal</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user