Observer platform redesign Phase 4: migrate charts to Tremor, redesign all pages
Some checks failed
Build and Push Docker Image / build (push) Failing after 23s

- Migrate 9 chart components from Nivo to @tremor/react (BarChart, AreaChart, DonutChart, ScatterChart)
- Remove @nivo/*, @react-spring/web dependencies (45 packages removed)
- Redesign dashboard: 6 stat tiles, competition pipeline, score distribution, juror workload, activity feed
- Add new /observer/projects page with search, filters, sorting, pagination, CSV export
- Restructure reports page from 5 tabs to 3 (Progress, Jurors, Scores & Analytics) with per-tab CSV export
- Redesign project detail: breadcrumb nav, score card header, 3-tab layout (Overview/Evaluations/Files)
- Update loading skeletons to match new layouts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 21:45:01 +01:00
parent 77cbc64b33
commit 8125ca6567
24 changed files with 3412 additions and 3401 deletions

998
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,11 +31,6 @@
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13", "@mantine/core": "^8.3.13",
"@mantine/hooks": "^8.3.13", "@mantine/hooks": "^8.3.13",
"@nivo/bar": "^0.99.0",
"@nivo/core": "^0.99.0",
"@nivo/line": "^0.99.0",
"@nivo/pie": "^0.99.0",
"@nivo/scatterplot": "^0.99.0",
"@notionhq/client": "^2.3.0", "@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
@@ -57,9 +52,9 @@
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@react-spring/web": "^10.0.3",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tremor/react": "^3.18.7",
"@trpc/client": "^11.0.0-rc.678", "@trpc/client": "^11.0.0-rc.678",
"@trpc/react-query": "^11.0.0-rc.678", "@trpc/react-query": "^11.0.0-rc.678",
"@trpc/server": "^11.0.0-rc.678", "@trpc/server": "^11.0.0-rc.678",

View File

@@ -0,0 +1,88 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
export default function ObserverLoading() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-32" />
</div>
<Skeleton className="h-10 w-[200px]" />
</div>
{/* 6 stat tiles */}
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-8 w-12" />
<Skeleton className="h-3 w-20" />
</div>
</CardContent>
</Card>
))}
</div>
{/* Pipeline */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<div className="flex gap-4 overflow-hidden">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24 w-48 shrink-0 rounded-lg" />
))}
</div>
</CardContent>
</Card>
{/* 3-col middle row */}
<div className="grid gap-4 lg:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent>
<Skeleton className="h-[200px] w-full" />
</CardContent>
</Card>
))}
</div>
{/* 2-col bottom row */}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3 w-16" />
</div>
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
export default function ObserverProjectsLoading() {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="h-9 w-28" />
</div>
<Card>
<CardHeader className="pb-3">
<Skeleton className="h-5 w-14" />
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Skeleton className="h-10 flex-1" />
<Skeleton className="h-10 w-full sm:w-[220px]" />
<Skeleton className="h-10 w-full sm:w-[180px]" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 space-y-2">
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,8 @@
import { ObserverProjectsContent } from '@/components/observer/observer-projects-content'
export const metadata = { title: 'Observer — Projects' }
export const dynamic = 'force-dynamic'
export default function ObserverProjectsPage() {
return <ObserverProjectsContent />
}

View File

@@ -0,0 +1,57 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
export default function ObserverReportsLoading() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<Skeleton className="h-8 w-32" />
<Skeleton className="mt-2 h-4 w-56" />
</div>
{/* Round selector */}
<Skeleton className="h-10 w-full sm:w-[300px]" />
{/* Tab bar */}
<Skeleton className="h-10 w-80" />
{/* 3 stat tiles */}
<div className="grid gap-4 sm:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-2 w-full" />
</CardContent>
</Card>
))}
</div>
{/* Chart skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-[300px] w-full" />
</CardContent>
</Card>
{/* Table skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-36" />
</CardHeader>
<CardContent className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
import type { PartialTheme } from '@nivo/theming'
// Brand colors from CLAUDE.md // Brand colors from CLAUDE.md
export const BRAND_DARK_BLUE = '#053d57' export const BRAND_DARK_BLUE = '#053d57'
export const BRAND_RED = '#de0f1e' export const BRAND_RED = '#de0f1e'
@@ -73,50 +71,6 @@ function lerpColor(a: string, b: string, t: number): string {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}` return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`
} }
/**
* Shared Nivo theme — brand fonts, clean grid, shadcn-style tooltips
*/
export const nivoTheme: PartialTheme = {
background: 'transparent',
text: {
fontSize: 12,
fill: '#374151',
fontFamily: 'Montserrat, system-ui, sans-serif',
},
axis: {
domain: {
line: { stroke: '#e5e7eb', strokeWidth: 1 },
},
ticks: {
line: { stroke: '#e5e7eb', strokeWidth: 1 },
text: { fontSize: 11, fill: '#6b7280' },
},
legend: {
text: { fontSize: 13, fill: '#374151', fontWeight: 600 },
},
},
grid: {
line: { stroke: '#f3f4f6', strokeWidth: 1 },
},
legends: {
text: { fontSize: 12, fill: '#374151' },
},
labels: {
text: { fontSize: 12, fill: '#374151', fontWeight: 500 },
},
tooltip: {
container: {
background: '#ffffff',
color: '#1f2937',
fontSize: 12,
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
padding: '8px 12px',
border: '1px solid #e5e7eb',
},
},
}
/** /**
* Helper: get color for a status value from STATUS_COLORS * Helper: get color for a status value from STATUS_COLORS
* Falls back to a neutral gray * Falls back to a neutral gray

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { ResponsiveBar } from '@nivo/bar' import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, scoreGradient } from './chart-theme' import { BRAND_TEAL } from './chart-theme'
interface CriteriaScoreData { interface CriteriaScoreData {
id: string id: string
@@ -15,13 +15,6 @@ interface CriteriaScoresProps {
data: CriteriaScoreData[] data: CriteriaScoreData[]
} }
type CriterionBarDatum = {
criterion: string
averageScore: number
fullName: string
count: number
}
export function CriteriaScoresChart({ data }: CriteriaScoresProps) { export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
if (!data?.length) return null if (!data?.length) return null
@@ -30,12 +23,10 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length ? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
: 0 : 0
const chartData: CriterionBarDatum[] = data.map((d) => ({ const chartData = data.map((d) => ({
criterion: criterion:
d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name, d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
averageScore: d.averageScore, 'Avg Score': parseFloat(d.averageScore.toFixed(2)),
fullName: d.name,
count: d.count,
})) }))
return ( return (
@@ -49,55 +40,17 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div style={{ height: '300px' }}> <BarChart
<ResponsiveBar data={chartData}
data={chartData} index="criterion"
keys={['averageScore']} categories={['Avg Score']}
indexBy="criterion" colors={[BRAND_TEAL] as string[]}
theme={nivoTheme} maxValue={10}
colors={(bar) => yAxisWidth={40}
scoreGradient(bar.data.averageScore as number) showLegend={false}
} className="h-[300px]"
valueScale={{ type: 'linear', max: 10 }} rotateLabelX={{ angle: -45, xAxisHeight: 60 }}
borderRadius={4} />
enableLabel={true}
label={(d) => {
const v = d.value
return v != null ? Number(v).toFixed(1) : ''
}}
labelSkipHeight={12}
labelTextColor="#ffffff"
axisBottom={{
tickRotation: -45,
}}
axisLeft={{
legend: 'Score',
legendPosition: 'middle',
legendOffset: -40,
}}
margin={{ top: 20, right: 20, bottom: 80, left: 50 }}
padding={0.25}
tooltip={({ data: rowData }) => (
<div
style={{
background: '#ffffff',
padding: '8px 12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
border: '1px solid #e5e7eb',
fontSize: 12,
}}
>
<strong>{rowData.fullName}</strong>
<br />
Average Score: {Number(rowData.averageScore).toFixed(2)}
<br />
Ratings: {rowData.count}
</div>
)}
animate={true}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { ResponsiveBar } from '@nivo/bar' import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, BRAND_COLORS } from './chart-theme' import { BRAND_COLORS } from './chart-theme'
interface StageComparison { interface StageComparison {
roundId: string roundId: string
@@ -36,16 +36,14 @@ export function CrossStageComparisonChart({
round.roundName.length > 20 round.roundName.length > 20
? round.roundName.slice(0, 20) + '...' ? round.roundName.slice(0, 20) + '...'
: round.roundName, : round.roundName,
projects: round.projectCount, Projects: round.projectCount,
evaluations: round.evaluationCount, Evaluations: round.evaluationCount,
completionRate: round.completionRate, 'Completion Rate': round.completionRate,
avgScore: round.averageScore 'Avg Score': round.averageScore
? parseFloat(round.averageScore.toFixed(2)) ? parseFloat(round.averageScore.toFixed(2))
: 0, : 0,
})) }))
const sharedMargin = { top: 10, right: 10, bottom: 40, left: 40 }
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -58,25 +56,16 @@ export function CrossStageComparisonChart({
<CardTitle className="text-sm font-medium">Projects</CardTitle> <CardTitle className="text-sm font-medium">Projects</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<div style={{ height: '200px' }}> <BarChart
<ResponsiveBar data={baseData}
data={baseData} index="name"
keys={['projects']} categories={['Projects']}
indexBy="name" colors={[BRAND_COLORS[0]] as string[]}
theme={nivoTheme} showLegend={false}
colors={[BRAND_COLORS[0]]} yAxisWidth={40}
borderRadius={4} className="h-[200px]"
enableLabel={true} rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
labelSkipHeight={12} />
labelTextColor="#ffffff"
margin={sharedMargin}
padding={0.3}
axisBottom={{
tickRotation: -25,
}}
animate={true}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -87,25 +76,16 @@ export function CrossStageComparisonChart({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<div style={{ height: '200px' }}> <BarChart
<ResponsiveBar data={baseData}
data={baseData} index="name"
keys={['evaluations']} categories={['Evaluations']}
indexBy="name" colors={[BRAND_COLORS[2]] as string[]}
theme={nivoTheme} showLegend={false}
colors={[BRAND_COLORS[2]]} yAxisWidth={40}
borderRadius={4} className="h-[200px]"
enableLabel={true} rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
labelSkipHeight={12} />
labelTextColor="#ffffff"
margin={sharedMargin}
padding={0.3}
axisBottom={{
tickRotation: -25,
}}
animate={true}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -116,30 +96,18 @@ export function CrossStageComparisonChart({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<div style={{ height: '200px' }}> <BarChart
<ResponsiveBar data={baseData}
data={baseData} index="name"
keys={['completionRate']} categories={['Completion Rate']}
indexBy="name" colors={[BRAND_COLORS[1]] as string[]}
theme={nivoTheme} showLegend={false}
colors={[BRAND_COLORS[1]]} maxValue={100}
valueScale={{ type: 'linear', max: 100 }} yAxisWidth={40}
borderRadius={4} valueFormatter={(v) => `${v}%`}
enableLabel={true} className="h-[200px]"
labelSkipHeight={12} rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
labelTextColor="#ffffff" />
valueFormat={(v) => `${v}%`}
margin={sharedMargin}
padding={0.3}
axisBottom={{
tickRotation: -25,
}}
axisLeft={{
format: (v) => `${v}%`,
}}
animate={true}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -150,26 +118,17 @@ export function CrossStageComparisonChart({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<div style={{ height: '200px' }}> <BarChart
<ResponsiveBar data={baseData}
data={baseData} index="name"
keys={['avgScore']} categories={['Avg Score']}
indexBy="name" colors={[BRAND_COLORS[0]] as string[]}
theme={nivoTheme} showLegend={false}
colors={[BRAND_COLORS[0]]} maxValue={10}
valueScale={{ type: 'linear', max: 10 }} yAxisWidth={40}
borderRadius={4} className="h-[200px]"
enableLabel={true} rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
labelSkipHeight={12} />
labelTextColor="#ffffff"
margin={sharedMargin}
padding={0.3}
axisBottom={{
tickRotation: -25,
}}
animate={true}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -1,10 +1,9 @@
'use client' 'use client'
import { ResponsivePie } from '@nivo/pie' import { DonutChart, BarChart } from '@tremor/react'
import { ResponsiveBar } from '@nivo/bar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { nivoTheme, BRAND_COLORS } from './chart-theme' import { BRAND_COLORS } from './chart-theme'
interface DiversityData { interface DiversityData {
total: number total: number
@@ -49,10 +48,10 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
) )
} }
// Top countries for pie chart (max 10, others grouped) // Top countries for donut chart (max 10, others grouped)
const topCountries = (data.byCountry || []).slice(0, 10) const topCountries = (data.byCountry || []).slice(0, 10)
const otherCountries = (data.byCountry || []).slice(10) const otherCountries = (data.byCountry || []).slice(10)
const countryPieData = otherCountries.length > 0 const countryData = otherCountries.length > 0
? [...topCountries, { ? [...topCountries, {
country: 'Others', country: 'Others',
count: otherCountries.reduce((sum, c) => sum + c.count, 0), count: otherCountries.reduce((sum, c) => sum + c.count, 0),
@@ -60,21 +59,19 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
}] }]
: topCountries : topCountries
const nivoPieData = countryPieData.map((c) => ({ const donutData = countryData.map((c) => ({
id: c.country === 'Others' ? 'Others' : c.country.toUpperCase(), name: getCountryName(c.country),
label: getCountryName(c.country),
value: c.count, value: c.count,
})) }))
// Pre-format category and ocean issue data for display const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({
const formattedCategories = (data.byCategory || []).slice(0, 10).map((c) => ({
category: formatLabel(c.category), category: formatLabel(c.category),
count: c.count, Count: c.count,
})) }))
const formattedOceanIssues = (data.byOceanIssue || []).slice(0, 15).map((o) => ({ const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
issue: formatLabel(o.issue), issue: formatLabel(o.issue),
count: o.count, Count: o.count,
})) }))
return ( return (
@@ -114,45 +111,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
<CardTitle>Geographic Distribution</CardTitle> <CardTitle>Geographic Distribution</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div style={{ height: '400px' }}> {donutData.length > 0 ? (
{nivoPieData.length > 0 ? <ResponsivePie <DonutChart
data={nivoPieData} data={donutData}
theme={nivoTheme} category="value"
colors={[...BRAND_COLORS]} index="name"
innerRadius={0.4} colors={[...BRAND_COLORS] as string[]}
padAngle={0.5} className="h-[400px]"
cornerRadius={3} />
activeOuterRadiusOffset={8} ) : (
margin={{ top: 40, right: 80, bottom: 80, left: 80 }} <p className="text-muted-foreground text-center py-8">No geographic data</p>
enableArcLinkLabels={true} )}
arcLinkLabelsSkipAngle={10}
arcLinkLabelsTextColor="#374151"
arcLinkLabelsThickness={2}
arcLinkLabelsColor={{ from: 'color' }}
enableArcLabels={true}
arcLabelsSkipAngle={10}
arcLabelsTextColor={{ from: 'color', modifiers: [['darker', 2]] }}
legends={[
{
anchor: 'bottom',
direction: 'row',
justify: false,
translateX: 0,
translateY: 56,
itemsSpacing: 0,
itemWidth: 100,
itemHeight: 18,
itemTextColor: '#374151',
itemDirection: 'left-to-right',
itemOpacity: 1,
symbolSize: 12,
symbolShape: 'circle',
},
]}
/> : (
<p className="text-muted-foreground text-center py-8">No geographic data</p>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -162,29 +131,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
<CardTitle>Competition Categories</CardTitle> <CardTitle>Competition Categories</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{formattedCategories.length > 0 ? ( {categoryData.length > 0 ? (
<div style={{ height: '400px' }}> <BarChart
<ResponsiveBar data={categoryData}
data={formattedCategories} index="category"
theme={nivoTheme} categories={['Count']}
keys={['count']} colors={[BRAND_COLORS[0]] as string[]}
indexBy="category" layout="horizontal"
layout="horizontal" yAxisWidth={120}
colors={[BRAND_COLORS[0]]} showLegend={false}
borderRadius={4} className="h-[400px]"
margin={{ top: 10, right: 30, bottom: 10, left: 120 }} />
padding={0.3}
enableLabel={true}
labelTextColor="#ffffff"
enableGridX={true}
enableGridY={false}
axisBottom={null}
axisLeft={{
tickSize: 0,
tickPadding: 8,
}}
/>
</div>
) : ( ) : (
<p className="text-muted-foreground text-center py-8">No category data</p> <p className="text-muted-foreground text-center py-8">No category data</p>
)} )}
@@ -193,38 +150,22 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
</div> </div>
{/* Ocean Issues */} {/* Ocean Issues */}
{formattedOceanIssues.length > 0 && ( {oceanIssueData.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ocean Issues Addressed</CardTitle> <CardTitle>Ocean Issues Addressed</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div style={{ height: '400px' }}> <BarChart
<ResponsiveBar data={oceanIssueData}
data={formattedOceanIssues} index="issue"
theme={nivoTheme} categories={['Count']}
keys={['count']} colors={[BRAND_COLORS[2]] as string[]}
indexBy="issue" showLegend={false}
layout="vertical" yAxisWidth={40}
colors={[BRAND_COLORS[2]]} className="h-[400px]"
borderRadius={4} rotateLabelX={{ angle: -35, xAxisHeight: 80 }}
margin={{ top: 20, right: 30, bottom: 80, left: 40 }} />
padding={0.3}
enableLabel={true}
labelTextColor="#ffffff"
enableGridX={false}
enableGridY={true}
axisBottom={{
tickSize: 0,
tickPadding: 8,
tickRotation: -35,
}}
axisLeft={{
tickSize: 0,
tickPadding: 8,
}}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { ResponsiveLine } from '@nivo/line' import { AreaChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, BRAND_DARK_BLUE } from './chart-theme' import { BRAND_DARK_BLUE, BRAND_TEAL } from './chart-theme'
interface TimelineDataPoint { interface TimelineDataPoint {
date: string date: string
@@ -17,26 +17,17 @@ interface EvaluationTimelineProps {
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) { export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
if (!data?.length) return null if (!data?.length) return null
const formattedData = data.map((d) => ({
...d,
dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}),
}))
const totalEvaluations = const totalEvaluations =
data.length > 0 ? data[data.length - 1].cumulative : 0 data.length > 0 ? data[data.length - 1].cumulative : 0
const lineData = [ const chartData = data.map((d) => ({
{ date: new Date(d.date).toLocaleDateString('en-US', {
id: 'Cumulative Evaluations', month: 'short',
data: formattedData.map((d) => ({ day: 'numeric',
x: d.dateFormatted, }),
y: d.cumulative, Cumulative: d.cumulative,
})), Daily: d.daily,
}, }))
]
return ( return (
<Card> <Card>
@@ -49,57 +40,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div style={{ height: '300px' }}> <AreaChart
<ResponsiveLine data={chartData}
data={lineData} index="date"
theme={nivoTheme} categories={['Cumulative', 'Daily']}
colors={[BRAND_DARK_BLUE]} colors={[BRAND_DARK_BLUE, BRAND_TEAL] as string[]}
enableArea={true} curveType="monotone"
areaOpacity={0.1} showGradient={true}
areaBaselineValue={0} yAxisWidth={50}
curve="monotoneX" className="h-[300px]"
pointSize={6} />
pointColor={BRAND_DARK_BLUE}
pointBorderWidth={2}
pointBorderColor="#ffffff"
useMesh={true}
enableSlices={formattedData.length >= 2 ? 'x' : false}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
if (!point) return null
const dataItem = formattedData.find(
(d) => d.dateFormatted === point.data.xFormatted
)
return (
<div
style={{
background: '#fff',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
>
<strong>{point.data.xFormatted}</strong>
<div>Cumulative: {point.data.yFormatted}</div>
{dataItem && <div>Daily: {dataItem.daily}</div>}
</div>
)
}}
margin={{ top: 20, right: 20, bottom: 50, left: 60 }}
axisBottom={{
tickRotation: -45,
legend: '',
legendOffset: 36,
}}
axisLeft={{
legend: 'Evaluations',
legendOffset: -50,
legendPosition: 'middle',
}}
yScale={{ type: 'linear', min: 0, max: 'auto' }}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,11 +1,6 @@
'use client' 'use client'
import { ResponsiveScatterPlot } from '@nivo/scatterplot' import { ScatterChart } from '@tremor/react'
import type {
ScatterPlotDatum,
ScatterPlotNodeProps,
} from '@nivo/scatterplot'
import { animated } from '@react-spring/web'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
@@ -17,7 +12,7 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { nivoTheme, BRAND_DARK_BLUE, BRAND_RED } from './chart-theme' import { BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
interface JurorMetric { interface JurorMetric {
userId: string userId: string
@@ -36,60 +31,6 @@ interface JurorConsistencyProps {
} }
} }
interface JurorDatum extends ScatterPlotDatum {
x: number
y: number
name: string
evaluations: number
isOutlier: boolean
}
function CustomNode({
node,
style,
blendMode,
isInteractive,
onMouseEnter,
onMouseMove,
onMouseLeave,
onClick,
}: ScatterPlotNodeProps<JurorDatum>) {
const fillColor = node.data.isOutlier ? BRAND_RED : BRAND_DARK_BLUE
return (
<animated.circle
cx={style.x}
cy={style.y}
r={style.size.to((s: number) => s / 2)}
fill={fillColor}
fillOpacity={0.7}
stroke={fillColor}
strokeWidth={1}
style={{ mixBlendMode: blendMode }}
onMouseEnter={
isInteractive && onMouseEnter
? (event) => onMouseEnter(node, event)
: undefined
}
onMouseMove={
isInteractive && onMouseMove
? (event) => onMouseMove(node, event)
: undefined
}
onMouseLeave={
isInteractive && onMouseLeave
? (event) => onMouseLeave(node, event)
: undefined
}
onClick={
isInteractive && onClick
? (event) => onClick(node, event)
: undefined
}
/>
)
}
export function JurorConsistencyChart({ data }: JurorConsistencyProps) { export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
if (!data?.jurors?.length) { if (!data?.jurors?.length) {
return ( return (
@@ -101,21 +42,17 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
) )
} }
const scatterData = [
{
id: 'Jurors',
data: data.jurors.map((j) => ({
x: parseFloat(j.averageScore.toFixed(2)),
y: parseFloat(j.stddev.toFixed(2)),
name: j.name,
evaluations: j.evaluationCount,
isOutlier: j.isOutlier,
})),
},
]
const outlierCount = data.jurors.filter((j) => j.isOutlier).length const outlierCount = data.jurors.filter((j) => j.isOutlier).length
const scatterData = data.jurors.map((j) => ({
'Average Score': parseFloat(j.averageScore.toFixed(2)),
'Std Deviation': parseFloat(j.stddev.toFixed(2)),
category: j.isOutlier ? 'Outlier' : 'Normal',
name: j.name,
evaluations: j.evaluationCount,
size: Math.max(8, Math.min(20, j.evaluationCount * 2)),
}))
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Scatter: Average Score vs Standard Deviation */} {/* Scatter: Average Score vs Standard Deviation */}
@@ -134,60 +71,15 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div style={{ height: '400px' }}> <ScatterChart
<ResponsiveScatterPlot<JurorDatum> data={scatterData}
data={scatterData} x="Average Score"
theme={nivoTheme} y="Std Deviation"
colors={[BRAND_DARK_BLUE]} category="category"
xScale={{ type: 'linear', min: 0, max: 10 }} size="size"
yScale={{ type: 'linear', min: 0, max: 'auto' }} colors={[BRAND_DARK_BLUE, BRAND_RED] as string[]}
axisBottom={{ className="h-[400px]"
legend: 'Average Score', />
legendPosition: 'middle',
legendOffset: 40,
}}
axisLeft={{
legend: 'Std Deviation',
legendPosition: 'middle',
legendOffset: -50,
}}
useMesh={true}
nodeSize={(node) =>
Math.max(8, Math.min(20, node.data.evaluations * 2))
}
nodeComponent={CustomNode}
margin={{ top: 20, right: 20, bottom: 60, left: 60 }}
tooltip={({ node }) => (
<div
style={{
background: '#fff',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
>
<strong>{node.data.name}</strong>
<div>Avg Score: {node.data.x}</div>
<div>Std Dev: {node.data.y}</div>
<div>Evaluations: {node.data.evaluations}</div>
</div>
)}
markers={[
{
axis: 'x',
value: data.overallAverage,
lineStyle: {
stroke: BRAND_RED,
strokeWidth: 2,
strokeDasharray: '6 4',
},
legend: `Avg: ${data.overallAverage.toFixed(1)}`,
legendPosition: 'top',
},
]}
/>
</div>
<p className="text-xs text-muted-foreground mt-2 text-center"> <p className="text-xs text-muted-foreground mt-2 text-center">
Dot size represents number of evaluations. Red dots indicate outlier Dot size represents number of evaluations. Red dots indicate outlier
jurors (2+ points from mean). jurors (2+ points from mean).

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { ResponsiveBar, type ComputedDatum } from '@nivo/bar' import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme } from './chart-theme' import { BRAND_DARK_BLUE } from './chart-theme'
interface JurorWorkloadData { interface JurorWorkloadData {
id: string id: string
@@ -16,14 +16,6 @@ interface JurorWorkloadProps {
data: JurorWorkloadData[] data: JurorWorkloadData[]
} }
type WorkloadBarDatum = {
juror: string
completed: number
remaining: number
completionRate: number
fullName: string
}
export function JurorWorkloadChart({ data }: JurorWorkloadProps) { export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
if (!data?.length) return null if (!data?.length) return null
@@ -36,12 +28,10 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
(a, b) => b.completionRate - a.completionRate, (a, b) => b.completionRate - a.completionRate,
) )
const chartData: WorkloadBarDatum[] = sortedData.map((d) => ({ const chartData = sortedData.map((d) => ({
juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name, juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
completed: d.completed, Completed: d.completed,
remaining: d.assigned - d.completed, Remaining: d.assigned - d.completed,
completionRate: d.completionRate,
fullName: d.name,
})) }))
return ( return (
@@ -55,66 +45,17 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div <BarChart
data={chartData}
index="juror"
categories={['Completed', 'Remaining']}
colors={[BRAND_DARK_BLUE, '#e5e7eb'] as string[]}
layout="horizontal"
stack={true}
yAxisWidth={160}
className={`h-[${Math.max(300, data.length * 35)}px]`}
style={{ height: `${Math.max(300, data.length * 35)}px` }} style={{ height: `${Math.max(300, data.length * 35)}px` }}
> />
<ResponsiveBar
data={chartData}
keys={['completed', 'remaining']}
indexBy="juror"
layout="horizontal"
theme={nivoTheme}
colors={['#053d57', '#e5e7eb']}
borderRadius={2}
enableLabel={true}
label={(d: ComputedDatum<WorkloadBarDatum>) => {
if (d.id === 'completed') {
return `${d.data.completionRate}%`
}
return ''
}}
labelSkipWidth={40}
labelTextColor={(d) => {
const datum = d as unknown as { data: ComputedDatum<WorkloadBarDatum> }
return datum.data.id === 'completed' ? '#ffffff' : '#374151'
}}
margin={{ top: 10, right: 30, bottom: 30, left: 160 }}
padding={0.25}
groupMode="stacked"
tooltip={({ id, value, data: rowData }) => (
<div
style={{
background: '#ffffff',
padding: '8px 12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
border: '1px solid #e5e7eb',
fontSize: 12,
}}
>
<strong>{rowData.fullName}</strong>
<br />
{id === 'completed' ? 'Completed' : 'Remaining'}: {value}
<br />
Completion: {rowData.completionRate}%
</div>
)}
legends={[
{
dataFrom: 'keys',
anchor: 'bottom',
direction: 'row',
translateY: 30,
itemsSpacing: 20,
itemWidth: 100,
itemHeight: 18,
symbolSize: 12,
symbolShape: 'square',
},
]}
animate={true}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { ResponsiveBar } from '@nivo/bar' import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, scoreGradient } from './chart-theme' import { BRAND_TEAL } from './chart-theme'
interface ProjectRankingData { interface ProjectRankingData {
id: string id: string
@@ -18,14 +18,6 @@ interface ProjectRankingsProps {
limit?: number limit?: number
} }
type RankingBarDatum = {
project: string
score: number
fullTitle: string
teamName: string
evaluationCount: number
}
export function ProjectRankingsChart({ export function ProjectRankingsChart({
data, data,
limit = 20, limit = 20,
@@ -37,21 +29,12 @@ export function ProjectRankingsChart({
if (!scoredData.length) return null if (!scoredData.length) return null
const averageScore =
scoredData.length > 0
? scoredData.reduce((sum, d) => sum + d.averageScore, 0) /
scoredData.length
: 0
const displayData = scoredData.slice(0, limit) const displayData = scoredData.slice(0, limit)
const chartData: RankingBarDatum[] = displayData.map((d) => ({ const chartData = displayData.map((d) => ({
project: project:
d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title, d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title,
score: d.averageScore, Score: parseFloat(d.averageScore.toFixed(2)),
fullTitle: d.title,
teamName: d.teamName ?? '',
evaluationCount: d.evaluationCount,
})) }))
return ( return (
@@ -65,75 +48,18 @@ export function ProjectRankingsChart({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div <BarChart
style={{ data={chartData}
height: `${Math.max(400, displayData.length * 30)}px`, index="project"
}} categories={['Score']}
> colors={[BRAND_TEAL] as string[]}
<ResponsiveBar layout="horizontal"
data={chartData} yAxisWidth={200}
keys={['score']} maxValue={10}
indexBy="project" showLegend={false}
layout="horizontal" className={`h-[${Math.max(400, displayData.length * 30)}px]`}
theme={nivoTheme} style={{ height: `${Math.max(400, displayData.length * 30)}px` }}
colors={(bar) => scoreGradient(bar.data.score as number)} />
valueScale={{ type: 'linear', max: 10 }}
borderRadius={4}
enableLabel={true}
label={(d) => {
const v = d.value
return v != null ? Number(v).toFixed(1) : ''
}}
labelSkipWidth={30}
labelTextColor="#ffffff"
margin={{ top: 10, right: 30, bottom: 30, left: 200 }}
padding={0.2}
markers={[
{
axis: 'x',
value: averageScore,
lineStyle: {
stroke: '#6b7280',
strokeWidth: 2,
strokeDasharray: '6 4',
},
legend: `Avg: ${averageScore.toFixed(1)}`,
legendPosition: 'top',
textStyle: {
fill: '#6b7280',
fontSize: 11,
},
},
]}
tooltip={({ data: rowData }) => (
<div
style={{
background: '#ffffff',
padding: '8px 12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
border: '1px solid #e5e7eb',
fontSize: 12,
}}
>
<strong>{rowData.fullTitle}</strong>
{rowData.teamName && (
<>
<br />
<span style={{ color: '#6b7280' }}>
{rowData.teamName}
</span>
</>
)}
<br />
Score: {Number(rowData.score).toFixed(2)}
<br />
Evaluations: {rowData.evaluationCount}
</div>
)}
animate={true}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { ResponsiveBar } from '@nivo/bar' import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, scoreGradient } from './chart-theme' import { BRAND_TEAL } from './chart-theme'
interface ScoreDistributionProps { interface ScoreDistributionProps {
data: { score: number; count: number }[] data: { score: number; count: number }[]
@@ -19,7 +19,7 @@ export function ScoreDistributionChart({
const chartData = data.map((d) => ({ const chartData = data.map((d) => ({
score: String(d.score), score: String(d.score),
count: d.count, Count: d.count,
})) }))
return ( return (
@@ -33,32 +33,15 @@ export function ScoreDistributionChart({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div style={{ height: '300px' }}> <BarChart
<ResponsiveBar data={chartData}
data={chartData} index="score"
keys={['count']} categories={['Count']}
indexBy="score" colors={[BRAND_TEAL] as (string)[]}
theme={nivoTheme} yAxisWidth={40}
colors={(bar) => scoreGradient(Number(bar.indexValue))} showLegend={false}
borderRadius={4} className="h-[300px]"
enableLabel={true} />
labelSkipHeight={12}
labelTextColor="#ffffff"
axisBottom={{
legend: 'Score',
legendPosition: 'middle',
legendOffset: 36,
}}
axisLeft={{
legend: 'Count',
legendPosition: 'middle',
legendOffset: -40,
}}
margin={{ top: 20, right: 20, bottom: 50, left: 50 }}
padding={0.2}
animate={true}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { ResponsivePie } from '@nivo/pie' import { DonutChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, getStatusColor, formatStatus } from './chart-theme' import { getStatusColor, formatStatus } from './chart-theme'
interface StatusDataPoint { interface StatusDataPoint {
status: string status: string
@@ -18,13 +18,13 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
const total = data.reduce((sum, item) => sum + item.count, 0) const total = data.reduce((sum, item) => sum + item.count, 0)
const pieData = data.map((d) => ({ const chartData = data.map((d) => ({
id: d.status, name: formatStatus(d.status),
label: formatStatus(d.status),
value: d.count, value: d.count,
color: getStatusColor(d.status),
})) }))
const colors = data.map((d) => getStatusColor(d.status))
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -36,43 +36,14 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div style={{ height: '300px' }}> <DonutChart
<ResponsivePie data={chartData}
data={pieData} category="value"
theme={nivoTheme} index="name"
colors={{ datum: 'data.color' }} colors={colors as string[]}
innerRadius={0.5} showLabel={true}
padAngle={0.7} className="h-[300px]"
cornerRadius={3} />
activeOuterRadiusOffset={8}
margin={{ top: 40, right: 80, bottom: 80, left: 80 }}
enableArcLinkLabels={true}
arcLinkLabelsSkipAngle={10}
arcLinkLabelsTextColor="#374151"
arcLinkLabelsThickness={2}
arcLinkLabelsColor={{ from: 'color' }}
enableArcLabels={true}
arcLabelsSkipAngle={10}
arcLabelsTextColor={{ from: 'color', modifiers: [['darker', 2]] }}
legends={[
{
anchor: 'bottom',
direction: 'row',
justify: false,
translateX: 0,
translateY: 56,
itemsSpacing: 0,
itemWidth: 100,
itemHeight: 18,
itemTextColor: '#374151',
itemDirection: 'left-to-right',
itemOpacity: 1,
symbolSize: 12,
symbolShape: 'circle',
},
]}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { BarChart3, Home } from 'lucide-react' import { BarChart3, Home, FolderKanban } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ObserverNavProps { interface ObserverNavProps {
@@ -14,6 +14,11 @@ export function ObserverNav({ user }: ObserverNavProps) {
href: '/observer', href: '/observer',
icon: Home, icon: Home,
}, },
{
name: 'Projects',
href: '/observer/projects',
icon: FolderKanban,
},
{ {
name: 'Reports', name: 'Reports',
href: '/observer/reports', href: '/observer/reports',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,487 @@
'use client'
import { useState, useCallback } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { StatusBadge } from '@/components/shared/status-badge'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { scoreGradient } from '@/components/charts/chart-theme'
import {
Search,
ChevronLeft,
ChevronRight,
ArrowUpDown,
ArrowUp,
ArrowDown,
ClipboardList,
Download,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useDebouncedCallback } from 'use-debounce'
export function ObserverProjectsContent() {
const router = useRouter()
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [roundFilter, setRoundFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [page, setPage] = useState(1)
const [perPage] = useState(20)
const [csvOpen, setCsvOpen] = useState(false)
const [csvExportData, setCsvExportData] = useState<
{ data: Record<string, unknown>[]; columns: string[] } | undefined
>(undefined)
const [csvLoading, setCsvLoading] = useState(false)
const debouncedSetSearch = useDebouncedCallback((value: string) => {
setDebouncedSearch(value)
setPage(1)
}, 300)
const handleSearchChange = (value: string) => {
setSearch(value)
debouncedSetSearch(value)
}
const handleRoundChange = (value: string) => {
setRoundFilter(value)
setPage(1)
}
const handleStatusChange = (value: string) => {
setStatusFilter(value)
setPage(1)
}
const handleSort = (column: 'title' | 'score' | 'evaluations') => {
if (sortBy === column) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(column)
setSortDir(column === 'title' ? 'asc' : 'desc')
}
setPage(1)
}
const clearFilters = () => {
setSearch('')
setDebouncedSearch('')
setRoundFilter('all')
setStatusFilter('all')
setPage(1)
}
const activeFilterCount =
(debouncedSearch ? 1 : 0) +
(roundFilter !== 'all' ? 1 : 0) +
(statusFilter !== 'all' ? 1 : 0)
const { data: programs } = trpc.program.list.useQuery(
{ includeStages: true },
{ refetchInterval: 30_000 },
)
const rounds =
programs?.flatMap((p) =>
(p.rounds ?? []).map((r: { id: string; name: string; status: string; roundType?: string }) => ({
id: r.id,
name: r.name,
programName: `${p.year} Edition`,
status: r.status,
roundType: r.roundType,
})),
) ?? []
const roundIdParam = roundFilter !== 'all' ? roundFilter : undefined
const { data: projectsData, isLoading: projectsLoading } =
trpc.analytics.getAllProjects.useQuery(
{
roundId: roundIdParam,
search: debouncedSearch || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
sortBy,
sortDir,
page,
perPage,
},
{ refetchInterval: 30_000 },
)
const handleRequestCsvData = useCallback(async () => {
setCsvLoading(true)
try {
const allData = await new Promise<typeof projectsData>((resolve) => {
resolve(projectsData)
})
if (!allData?.projects) {
setCsvLoading(false)
return undefined
}
const rows = allData.projects.map((p) => ({
title: p.title,
teamName: p.teamName ?? '',
country: p.country ?? '',
roundName: p.roundName ?? '',
status: p.status,
averageScore: p.averageScore !== null ? p.averageScore.toFixed(2) : '',
evaluationCount: p.evaluationCount,
}))
const result = {
data: rows,
columns: ['title', 'teamName', 'country', 'roundName', 'status', 'averageScore', 'evaluationCount'],
}
setCsvExportData(result)
setCsvLoading(false)
return result
} catch {
setCsvLoading(false)
return undefined
}
}, [projectsData])
const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => {
if (sortBy !== column)
return <ArrowUpDown className="ml-1 inline h-3 w-3 text-muted-foreground/50" />
return sortDir === 'asc' ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
)
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">All Projects</h1>
<p className="text-muted-foreground">
{projectsData
? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} total`
: 'Loading projects...'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => setCsvOpen(true)}>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Filters</CardTitle>
{activeFilterCount > 0 && (
<CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{activeFilterCount} active</Badge>
<button
type="button"
onClick={clearFilters}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3 w-3" />
Clear all
</button>
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by title or team..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={roundFilter} onValueChange={handleRoundChange}>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="All Rounds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Rounds</SelectItem>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={handleStatusChange}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="SUBMITTED">Submitted</SelectItem>
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
<SelectItem value="ASSIGNED">Assigned</SelectItem>
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{projectsLoading ? (
<Card>
<CardContent className="pt-6 space-y-2">
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
) : projectsData && projectsData.projects.length > 0 ? (
<>
<div className="hidden md:block">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">
<button
type="button"
onClick={() => handleSort('title')}
className="inline-flex items-center hover:text-foreground transition-colors"
>
Project
<SortIcon column="title" />
</button>
</TableHead>
<TableHead>Country</TableHead>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>
<button
type="button"
onClick={() => handleSort('score')}
className="inline-flex items-center hover:text-foreground transition-colors"
>
Score
<SortIcon column="score" />
</button>
</TableHead>
<TableHead>
<button
type="button"
onClick={() => handleSort('evaluations')}
className="inline-flex items-center hover:text-foreground transition-colors"
>
Jurors
<SortIcon column="evaluations" />
</button>
</TableHead>
<TableHead className="pr-6 w-10" />
</TableRow>
</TableHeader>
<TableBody>
{projectsData.projects.map((project) => (
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/observer/projects/${project.id}`)}
>
<TableCell className="pl-6 max-w-[260px]">
<Link
href={`/observer/projects/${project.id}` as Route}
className="font-medium hover:underline truncate block"
onClick={(e) => e.stopPropagation()}
>
{project.title}
</Link>
{project.teamName && (
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
</p>
)}
</TableCell>
<TableCell className="text-sm">
{project.country ?? '-'}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs whitespace-nowrap">
{project.roundName}
</Badge>
</TableCell>
<TableCell>
<StatusBadge status={project.status} />
</TableCell>
<TableCell>
{project.averageScore !== null ? (
<div className="flex items-center gap-2">
<span className="tabular-nums w-8 text-sm">
{project.averageScore.toFixed(1)}
</span>
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${(project.averageScore / 10) * 100}%`,
backgroundColor: scoreGradient(project.averageScore),
}}
/>
</div>
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell className="tabular-nums text-sm">
{project.evaluationCount}
</TableCell>
<TableCell className="pr-6">
<Link
href={`/observer/projects/${project.id}` as Route}
onClick={(e) => e.stopPropagation()}
>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<div className="space-y-3 md:hidden">
{projectsData.projects.map((project) => (
<Link
key={project.id}
href={`/observer/projects/${project.id}` as Route}
>
<Card className="transition-colors hover:bg-muted/50">
<CardContent className="pt-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-sm leading-tight truncate">
{project.title}
</p>
{project.teamName && (
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
<StatusBadge status={project.status} />
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs">
{project.roundName}
</Badge>
<div className="flex gap-3">
<span>
Score:{' '}
{project.averageScore !== null
? project.averageScore.toFixed(1)
: '-'}
</span>
<span>
{project.evaluationCount} eval
{project.evaluationCount !== 1 ? 's' : ''}
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {projectsData.page} of {projectsData.totalPages} &middot;{' '}
{projectsData.total} result{projectsData.total !== 1 ? 's' : ''}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setPage((p) => Math.min(projectsData.totalPages, p + 1))
}
disabled={page >= projectsData.totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
) : (
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border border-dashed py-16 text-center',
)}
>
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-3 font-medium">
{activeFilterCount > 0 ? 'No projects match your filters' : 'No projects found'}
</p>
{activeFilterCount > 0 && (
<Button variant="ghost" size="sm" className="mt-2" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
)}
<CsvExportDialog
open={csvOpen}
onOpenChange={setCsvOpen}
exportData={csvExportData}
isLoading={csvLoading}
filename="observer-projects"
onRequestData={handleRequestCsvData}
/>
</div>
)
}

View File

@@ -627,92 +627,6 @@ export const analyticsRouter = router({
return { total, byCountry, byCategory, byOceanIssue, byTag } return { total, byCountry, byCategory, byOceanIssue, byTag }
}), }),
/**
* Get year-over-year stats across all rounds in a program
*/
getYearOverYear: observerProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const competitions = await ctx.prisma.competition.findMany({
where: { programId: input.programId },
include: {
rounds: {
select: { id: true, name: true, createdAt: true },
orderBy: { createdAt: 'asc' },
},
},
orderBy: { createdAt: 'asc' },
})
const allRounds = competitions.flatMap((c) => c.rounds)
const roundIds = allRounds.map((r) => r.id)
if (roundIds.length === 0) return []
// Batch: fetch assignments, evaluations, and distinct projects in 3 queries
const [assignmentCounts, evaluations, projectAssignments] = await Promise.all([
ctx.prisma.assignment.groupBy({
by: ['roundId'],
where: { roundId: { in: roundIds } },
_count: true,
}),
ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: { in: roundIds } },
status: 'SUBMITTED',
},
select: { globalScore: true, assignment: { select: { roundId: true } } },
}),
ctx.prisma.assignment.findMany({
where: { roundId: { in: roundIds } },
select: { roundId: true, projectId: true },
distinct: ['roundId', 'projectId'],
}),
])
const assignmentCountMap = new Map(assignmentCounts.map((a) => [a.roundId, a._count]))
// Group evaluation scores by round
const scoresByRound = new Map<string, number[]>()
const evalCountByRound = new Map<string, number>()
for (const e of evaluations) {
const rid = e.assignment.roundId
evalCountByRound.set(rid, (evalCountByRound.get(rid) ?? 0) + 1)
if (e.globalScore !== null) {
if (!scoresByRound.has(rid)) scoresByRound.set(rid, [])
scoresByRound.get(rid)!.push(e.globalScore)
}
}
// Count distinct projects per round
const projectsByRound = new Map<string, number>()
for (const pa of projectAssignments) {
projectsByRound.set(pa.roundId, (projectsByRound.get(pa.roundId) ?? 0) + 1)
}
return allRounds.map((round) => {
const scores = scoresByRound.get(round.id) ?? []
const assignmentCount = assignmentCountMap.get(round.id) ?? 0
const evaluationCount = evalCountByRound.get(round.id) ?? 0
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
const averageScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
return {
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
projectCount: projectsByRound.get(round.id) ?? 0,
evaluationCount,
completionRate,
averageScore,
}
})
}),
/** /**
* Get dashboard stats (optionally scoped to a round) * Get dashboard stats (optionally scoped to a round)
*/ */
@@ -875,61 +789,86 @@ export const analyticsRouter = router({
}, },
}) })
// For each round, get assignment coverage and evaluation completion // Batch all queries by roundIds to avoid N+1
const roundOverviews = await Promise.all( const roundIds = rounds.map((r) => r.id)
rounds.map(async (round) => {
const [
projectRoundStates,
totalAssignments,
completedEvaluations,
distinctJurors,
] = await Promise.all([
ctx.prisma.projectRoundState.groupBy({
by: ['state'],
where: { roundId: round.id },
_count: true,
}),
ctx.prisma.assignment.count({
where: { roundId: round.id },
}),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: round.id },
}),
])
const stateBreakdown = projectRoundStates.map((ps) => ({ const [
state: ps.state, allProjectRoundStates,
count: ps._count, allAssignmentCounts,
})) allCompletedEvals,
allDistinctJurors,
] = await Promise.all([
ctx.prisma.projectRoundState.groupBy({
by: ['roundId', 'state'],
where: { roundId: { in: roundIds } },
_count: true,
}),
ctx.prisma.assignment.groupBy({
by: ['roundId'],
where: { roundId: { in: roundIds } },
_count: true,
}),
// groupBy on relation field not supported, use raw count per round
ctx.prisma.$queryRaw<{ roundId: string; count: bigint }[]>`
SELECT a."roundId", COUNT(e.id)::bigint as count
FROM "Evaluation" e
JOIN "Assignment" a ON e."assignmentId" = a.id
WHERE a."roundId" = ANY(${roundIds}) AND e.status = 'SUBMITTED'
GROUP BY a."roundId"
`,
ctx.prisma.assignment.groupBy({
by: ['roundId', 'userId'],
where: { roundId: { in: roundIds } },
}),
])
const totalProjects = projectRoundStates.reduce((sum, ps) => sum + ps._count, 0) // Build lookup maps
const completionRate = totalAssignments > 0 const statesByRound = new Map<string, { state: string; count: number }[]>()
? Math.round((completedEvaluations / totalAssignments) * 100) for (const ps of allProjectRoundStates) {
: 0 const list = statesByRound.get(ps.roundId) || []
list.push({ state: ps.state, count: ps._count })
statesByRound.set(ps.roundId, list)
}
return { const assignmentCountByRound = new Map<string, number>()
roundId: round.id, for (const ac of allAssignmentCounts) {
roundName: round.name, assignmentCountByRound.set(ac.roundId, ac._count)
roundType: round.roundType, }
roundStatus: round.status,
sortOrder: round.sortOrder, const completedEvalsByRound = new Map<string, number>()
totalProjects, for (const ce of allCompletedEvals) {
stateBreakdown, completedEvalsByRound.set(ce.roundId, Number(ce.count))
totalAssignments, }
completedEvaluations,
pendingEvaluations: totalAssignments - completedEvaluations, const jurorCountByRound = new Map<string, number>()
completionRate, for (const j of allDistinctJurors) {
jurorCount: distinctJurors.length, jurorCountByRound.set(j.roundId, (jurorCountByRound.get(j.roundId) || 0) + 1)
} }
})
) const roundOverviews = rounds.map((round) => {
const stateBreakdown = statesByRound.get(round.id) || []
const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0)
const totalAssignments = assignmentCountByRound.get(round.id) || 0
const completedEvaluations = completedEvalsByRound.get(round.id) || 0
const completionRate = totalAssignments > 0
? Math.round((completedEvaluations / totalAssignments) * 100)
: 0
return {
roundId: round.id,
roundName: round.name,
roundType: round.roundType,
roundStatus: round.status,
sortOrder: round.sortOrder,
totalProjects,
stateBreakdown,
totalAssignments,
completedEvaluations,
pendingEvaluations: totalAssignments - completedEvaluations,
completionRate,
jurorCount: jurorCountByRound.get(round.id) || 0,
}
})
return { return {
competitionId: input.competitionId, competitionId: input.competitionId,
@@ -972,7 +911,7 @@ export const analyticsRouter = router({
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {}
if (input.roundId) { if (input.roundId) {
where.assignments = { some: { roundId: input.roundId } } where.projectRoundStates = { some: { roundId: input.roundId } }
} }
if (input.status) { if (input.status) {
@@ -1370,4 +1309,47 @@ export const analyticsRouter = router({
allRequirements, allRequirements,
} }
}), }),
/**
* Activity feed — recent audit log entries for observer dashboard
*/
getActivityFeed: observerProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(10) }).optional())
.query(async ({ ctx, input }) => {
const limit = input?.limit ?? 10
const entries = await ctx.prisma.decisionAuditLog.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
eventType: true,
entityType: true,
entityId: true,
actorId: true,
detailsJson: true,
createdAt: true,
},
})
// Batch-fetch actor names
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
const actors = actorIds.length > 0
? await ctx.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { id: true, name: true },
})
: []
const actorMap = new Map(actors.map((a) => [a.id, a.name]))
return entries.map((entry) => ({
id: entry.id,
eventType: entry.eventType,
entityType: entry.entityType,
entityId: entry.entityId,
actorName: entry.actorId ? actorMap.get(entry.actorId) ?? null : null,
details: entry.detailsJson as Record<string, unknown> | null,
createdAt: entry.createdAt,
}))
}),
}) })

View File

@@ -20,9 +20,9 @@ export const fileRouter = router({
}) })
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) { if (!isAdminOrObserver) {
const file = await ctx.prisma.projectFile.findFirst({ const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey }, where: { bucket: input.bucket, objectKey: input.objectKey },
select: { select: {
@@ -283,9 +283,9 @@ export const fileRouter = router({
roundId: z.string().optional(), roundId: z.string().optional(),
})) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) { if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([ const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({ ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId }, where: { userId: ctx.user.id, projectId: input.projectId },
@@ -348,9 +348,9 @@ export const fileRouter = router({
roundId: z.string(), roundId: z.string(),
})) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) { if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([ const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({ ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId }, where: { userId: ctx.user.id, projectId: input.projectId },
@@ -468,9 +468,9 @@ export const fileRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) { if (!isAdminOrObserver) {
// Check user has access to the project (assigned or team member) // Check user has access to the project (assigned or team member)
const [assignment, mentorAssignment, teamMembership] = await Promise.all([ const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({ ctx.prisma.assignment.findFirst({
@@ -652,9 +652,9 @@ export const fileRouter = router({
}) })
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdmin) { if (!isAdminOrObserver) {
const [assignment, mentorAssignment, teamMembership] = await Promise.all([ const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({ ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId }, where: { userId: ctx.user.id, projectId: input.projectId },

View File

@@ -6,6 +6,7 @@ const config: Config = {
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}', './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}', './src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./node_modules/@tremor/**/*.{js,ts,jsx,tsx}',
], ],
theme: { theme: {
container: { container: {