Compare commits

...

87 Commits

Author SHA1 Message Date
3e70de3a5a Add Anthropic API, test environment, remove locale settings
Feature 1: Anthropic API Integration
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option

Feature 2: Remove Locale Settings UI
- Strip Localization tab from admin settings
- Remove i18n settings from router inferCategory and getFeatureFlags
- Keep franc document language detection intact

Feature 3: Test Environment with Role Impersonation
- Add isTest field to User, Program, Project, Competition models
- Test environment service: create/teardown with realistic dummy data
- JWT-based impersonation for test users (@test.local emails)
- Impersonation banner with quick-switch between test roles
- Test environment panel in admin settings (SUPER_ADMIN only)
- Email redirect: @test.local emails routed to admin with [TEST] prefix
- Complete data isolation: 45+ isTest:false filters across platform
  - All global queries on User/Project/Program/Competition
  - AI services blocked from processing test data
  - Cron jobs skip test rounds/users
  - Analytics/exports exclude test data
  - Admin layout/pickers hide test programs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:28:07 +01:00
f42b452899 Add Anthropic API integration, remove locale settings UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m15s
Anthropic API:
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option
- Add provider field to AIUsageLog for cross-provider cost tracking

Locale Settings Removal:
- Strip Localization tab from admin settings (mobile + desktop)
- Remove i18n settings from router and feature flags
- Remove LOCALIZATION from SettingCategory enum
- Keep franc document language detection intact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:26:59 +01:00
161cd1684a Fix observer reports: charts, filtering, project preview, dashboard stats
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m32s
- Rewrite diversity metrics: horizontal bar charts for ocean issues and
  geographic distribution (replaces unreadable vertical/donut charts)
- Rewrite juror score heatmap: expandable table with score distribution
- Rewrite juror consistency: horizontal bar visual with juror names
- Merge filtering tabs into single screening view with per-project
  AI reasoning and expandable rows
- Add project preview dialog for juror performance table
- Fix status breakdown for evaluation rounds (Fully/Partially/Not Reviewed)
- Show active round name instead of count on observer dashboard
- Move Global tab to last position, default to first round-specific tab
- Add 4-card stats layout for evaluation with reviews/project ratio
- Fix oceanIssue field (singular) and remove non-existent aiSummary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 10:12:21 +01:00
2e4b95f29c Add round-type-specific observer reports with dynamic tabs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s
Refactor the observer reports page from a static 3-tab layout to a
dynamic tab system that adapts to each round type (INTAKE, FILTERING,
EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION). Adds a
persistent Global tab for edition-wide analytics, juror score heatmap,
expandable juror assignment rows, filtering screening bar, and
deliberation results with tie detection.

- Add 5 observer proxy procedures to analytics router
- Create JurorScoreHeatmap, ExpandableJurorTable, FilteringScreeningBar
- Create 8 round-type tab components + GlobalAnalyticsTab
- Reduce reports page from 914 to ~190 lines (thin dispatcher)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 09:29:26 +01:00
ee3bfec8b0 Add Tremor design tokens for Tailwind v4 compatibility
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s
Tremor v3 expects its TW3 plugin to register tremor-* design tokens
(tremor-content, tremor-background, tremor-border, etc.). Since TW4
has no v3 plugin support, define these tokens manually in @theme and
safelist the utility classes via @source inline().

This fixes chart axis labels, grid lines, and tooltips rendering
without proper colors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:43:11 +01:00
8e607478d5 Fix Tremor chart colors: safelist dynamic utility classes for Tailwind v4
Tremor constructs class names via template literals (e.g. fill-${color}-${shade})
which Tailwind v4's scanner cannot detect statically. Added @source inline()
directives to explicitly safelist all color×shade×property combinations needed
by Tremor chart components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:39:24 +01:00
6d4ee93ab3 Fix round completion rate: use evaluations/assignments, closed rounds=100%
The round breakdown was showing 200% for active rounds (assignments/projects)
and 0% for closed rounds. Now correctly computes evaluations/assignments for
active rounds and shows 100% for closed/archived rounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:34:42 +01:00
350e9b96e8 Fix Tremor chart colors: add @source for Tailwind v4 to scan Tremor classes
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s
Tremor generates Tailwind utility classes dynamically (fill-blue-500,
bg-emerald-500, etc). Tailwind v4 auto-content detection doesn't scan
node_modules, so these classes were missing from CSS output, causing
all charts to render in black.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:14:59 +01:00
533d8cb8e5 Replace generic stat cards with clean horizontal stats strip
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:26:04 +01:00
4f73ba5a0e Fix reports: status breakdown uses round states, filter boolean criteria, replace insight tiles with country chart
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m47s
- getStatusBreakdown now uses ProjectRoundState when a specific round is selected
  (fixes donut showing all "Eligible")
- Filter out boolean/section_header criteria from getCriteriaScores
  (removes "Move to the Next Stage?" from bar chart)
- Replace 6 insight tiles with Top Countries horizontal bar chart
- Add round-level state labels/colors to chart-theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:00:55 +01:00
26e8830df2 Revamp chart colors: replace bland cyan/teal with vibrant blue/indigo/amber palette + fix tooltip indicators
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:54:14 +01:00
6e697cb5d8 Extend Recently Reviewed card to match sibling heights
Some checks are pending
Build and Push Docker Image / build (push) Waiting to run
Use flex-1 on the Recently Reviewed card so it stretches to fill the
remaining vertical space in the left column, aligning its bottom with
Juror Workload and Activity Feed. Add className prop to AnimatedCard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:49:15 +01:00
a714c56e81 Fix % recommended: derive from boolean criteria when binaryDecision is null
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
When scoringMode is not 'binary', binaryDecision is null even though
jurors answer boolean criteria (e.g. "Do you recommend?"). Now falls
back to checking boolean values in criterionScoresJson. Hides the
recommendation line entirely when no boolean data exists.

Fixed in both analytics.ts (observer) and project.ts (admin).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:47:48 +01:00
a6b6763fa4 Simplify project detail: back button, cleaner files, fix round inference
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Replace breadcrumb with "Back to Projects" button on observer detail
- Remove submission links from observer project info
- Simplify files tab: remove redundant requirements checklist, show only
  FileViewer (observer + admin)
- Fix round history: infer earlier rounds as PASSED when later round is
  active (e.g. R2 shows Passed when project is active in R3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:45:31 +01:00
d717040f03 Observer: fix round history, match admin project info, add AI rejection reason
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s
- Round history infers rejection round when ProjectRoundState lacks explicit
  REJECTED state; shows red XCircle + badge, dims unreached rounds
- Project info section now matches admin: description, location, founded,
  submission links, expertise tags, internal notes, created/updated dates
- Fetch FilteringResult for rejected projects; display AI reasoning + confidence
- Remove cross-round comparison from reports, replace with scoring/criteria insights
- Remove unused AI synthesis placeholder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:30:14 +01:00
9f7b76b3cb Dashboard layout overhaul + fix Tremor chart colors and tooltips
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m55s
- Restructure dashboard: score distribution + recently reviewed stacked in left column,
  full-width map at bottom, activity feed in middle row
- Show all jurors in scrollable workload list (not just top 5)
- Filter recently reviewed to exclude rejected/not-reviewed projects
- Filter transition audit logs from activity feed
- Remove completion progress bar from stat tile for equal card heights
- Fix all Tremor charts: switch hex colors to named palette (cyan/teal/emerald/amber/rose)
  to fix black bar rendering
- Fix transparent chart tooltips with global CSS overrides
- Remove tilted text labels from cross-round comparison charts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:09:06 +01:00
213efdba87 Observer platform: mobile fixes, data/UX overhaul, animated nav
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m41s
- Fix dashboard default round selection to target active round instead of R1
- Move edition selector from dashboard header to hamburger menu via shared context
- Add observer-friendly status labels (Not Reviewed / Under Review / Reviewed)
- Fix pipeline completion: closed rounds show 100%, cap all rates at 100%
- Round badge on projects list shows furthest round reached
- Hide scores/evals for projects with zero evaluations
- Enhance project detail round history with pass/reject indicators from ProjectRoundState
- Remove irrelevant fields (Org Type, Budget, Duration) from project detail
- Clickable juror workload with expandable project assignments
- Humanize activity feed with icons and readable messages
- Fix jurors table: responsive card layout on mobile
- Fix criteria chart: horizontal bars for readable labels on mobile
- Animate hamburger menu open/close with CSS grid transition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:45:56 +01:00
5eea430ebd Fix Docker build: add .npmrc for Tremor peer dep conflict
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m44s
@tremor/react@3.18.7 requires react@^18 but project uses react@19.
Adding .npmrc with legacy-peer-deps=true and copying it in Dockerfiles
so npm ci resolves correctly. Also fix implicit any in seed file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:56:26 +01:00
8125ca6567 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>
2026-02-20 21:45:01 +01:00
77cbc64b33 Add missing deps: @radix-ui/react-toggle, @react-spring/web
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m59s
These packages are imported by new chart and toggle components but were
never added to package.json, causing the observer reports page to crash
client-side on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:02:40 +01:00
Matt
03c59c188e Add observer project detail page with files, evaluations & reviews
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
New page at /observer/projects/[projectId] showing project info,
documents grouped by round requirements, and jury evaluations with
click-through to full review details. Dashboard table rows now link
to project detail. Also cleans up redundant programName prefixes
and fixes chart edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:39:53 +01:00
Matt
f1062f4805 Fix admin getting juror assignment email on reshuffle/COI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m46s
notifyAdmins was using BATCH_ASSIGNED notification type, which triggers
the juror assignment email template ('X Projects Assigned'). Admins
received confusing emails that looked like they were assigned projects.

Changed to EVALUATION_MILESTONE type for admin-facing reshuffle/COI
notifications. Also included top receivers in admin notification message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:35:21 +01:00
Matt
34fdd0ba8e Add human-readable reshuffle details to audit log page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
Audit log now renders JUROR_DROPOUT_RESHUFFLE and COI_REASSIGNMENT
entries as formatted tables with resolved juror names instead of raw
JSON with opaque IDs. Uses new user.resolveNames endpoint to batch-
lookup user IDs. Also adds missing action types to the filter dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:23:10 +01:00
Matt
0d0571ebf2 Fix reassignment scoping bug + add reassignment history
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Bug fix: reassignDroppedJuror, reassignAfterCOI, and getSuggestions all
fell back to querying ALL JURY_MEMBER users globally when the round had
no juryGroupId. This caused projects to be assigned to jurors who are no
longer active in the jury pool. Now scopes to jury group members when
available, otherwise to jurors already assigned to the round.

Also adds getSuggestions jury group scoping (matching runAIAssignmentJob).

New feature: Reassignment History panel on admin round page (collapsible)
shows per-project detail of where dropped/COI-reassigned projects went.
Reconstructs retroactive data from audit log timestamps + MANUAL
assignments for pre-fix entries. Future entries log full move details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:18:49 +01:00
Matt
0607d79484 Fix observer analytics crash: guard Nivo edge cases
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m41s
- Disable enableSlices on ResponsiveLine with single data point (causes
  null reference in Nivo internal slice computation)
- Add null check for slice.points[0] in timeline tooltip
- Guard ResponsivePie from empty data array in diversity metrics
- Add fallback for scoreDistribution.distribution on both
  observer and admin reports pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:09:43 +01:00
Matt
57a16d089d Fix juror drop: remove from jury group + reassign projects
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
The reassignDroppedJuror flow was missing a key step — after
reshuffling unsubmitted projects to other jurors, the dropped juror
was not removed from the jury group. This meant they could be
re-assigned in future assignment runs. Now deletes the JuryGroupMember
record after reshuffle, logs removal in audit, and updates the
confirmation dialog to reflect the full action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:57:15 +01:00
Matt
fbcbf895be Add defensive null guards to all chart components and analytics
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
All 9 chart components now have early-return null/empty checks before
calling .map() on data props. The diversity-metrics chart guards all
nested array fields (byCountry, byCategory, byOceanIssue, byTag).
Analytics backend guards p.tags in getDiversityMetrics. This prevents
any "Cannot read properties of null (reading 'map')" crashes even if
upstream data shapes are unexpected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:42:31 +01:00
Matt
4519bc6080 Fix criteria validation using wrong form + fix reports page null crash
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m38s
1. Evaluation submit: The requireAllCriteriaScored validation was
   querying findFirst({ roundId, isActive: true }) to get the form
   criteria, instead of using the evaluation's stored formId. If an
   admin ever re-saved the evaluation form (creating a new version
   with new criterion IDs), jurors who started evaluating before the
   re-save had scores keyed to old IDs that didn't match the new
   form. Now uses evaluation.form (the form assigned at start time).

2. Observer reports page: Two .map() calls on p.stages lacked null
   guards, causing "Cannot read properties of null (reading 'map')"
   crash. Added (p.stages || []) guards matching the pattern already
   used in CrossStageTab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:16:09 +01:00
Matt
bf02684736 Fix COI audit log always saying conflict + fix boolean criteria submission
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m19s
1. COI audit log: The declareCOI mutation always logged action
   'COI_DECLARED' regardless of whether the user clicked "No Conflict"
   or "Yes, I Have a Conflict". Now uses 'COI_NO_CONFLICT' when
   hasConflict is false, showing "confirmed no conflict of interest"
   in the audit trail.

2. Evaluation submission: The requireAllCriteriaScored validation
   only accepted numeric values (typeof === 'number'), but boolean
   criteria (yes/no questions) store true/false. This caused jurors
   to get "Missing scores for criteria: criterion-xxx" errors even
   after completing all fields. Now correctly validates boolean
   criteria with typeof === 'boolean'. Also improved the error
   message to show criterion labels instead of cryptic IDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:53:54 +01:00
Matt
d9d6a63e4a fix(assignments): make reshuffle concurrency-safe; preserve juryGroupId
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m16s
2026-02-20 03:48:17 +01:00
Claw
c7f20e2f32 fix(assignments): complete dropped juror reshuffle with type-safe logic
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 00:07:30 +01:00
Claw
d3a63b0354 feat(assignments): reshuffle dropped juror projects within caps
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m38s
2026-02-19 23:12:55 +01:00
Matt
9d945c33f9 Observer platform overhaul: Nivo charts, round-type stats, UX improvements
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m29s
Phase 1: Fix 6 backend data bugs in analytics.ts (roundName filtering,
unscored projects, criteria scores, activeRoundCount scoping, email
privacy leaks in juror consistency + workload)

Phase 2-3: Migrate all 9 chart components from Recharts to Nivo
(@nivo/bar, @nivo/line, @nivo/pie, @nivo/scatterplot) with shared brand
theme, scoreGradient colors, and STATUS_COLORS map. Fixes scatter plot
outlier coloring and pie chart label visibility bugs.

Phase 4: Add round-type-aware stats (getRoundTypeStats backend +
RoundTypeStatsCards component) showing appropriate metrics per round
type (intake/filtering/evaluation/submission/mentoring/live/deliberation).

Phase 5: UX improvements — Stage→Round terminology, clickable dashboard
round links, URL-based round selection (?round=), round type indicators
in selectors, accessible Toggle-based cross-round comparison, sortable
project table columns (title/score/evaluations), brand score colors on
dashboard bar chart with aria labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:44:38 +01:00
Matt
8ae8145d86 Default observer reports to active round instead of first round
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:52:07 +01:00
Matt
0ff84686f0 Auto-reassign projects when juror declares conflict of interest
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
When a juror declares COI, the system now automatically:
- Finds an eligible replacement juror (not at capacity, no COI, not already assigned)
- Deletes the conflicted assignment and creates a new one
- Notifies the replacement juror and admins
- Load-balances by picking the juror with fewest current assignments

Also adds:
- "Reassign (COI)" action in assignment table dropdown with COI badge indicator
- Admin "Reassign to another juror" in COI review now triggers actual reassignment
- Per-juror notify button is now always visible (not just on hover)
- reassignCOI admin procedure for retroactive manual reassignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:30:01 +01:00
Matt
1dcc7a5990 Add per-juror notify button in Jury Progress section
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m45s
Adds a mail icon on hover for each juror row in the Jury Progress
table, allowing admins to send assignment notifications to individual
jurors instead of only bulk-notifying all at once.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:18:07 +01:00
Matt
725d88fec2 Show full country names instead of ISO codes on projects pages
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m38s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:55:04 +01:00
Matt
c62a335424 Fix email links using relative paths — prepend baseUrl for absolute URLs
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m49s
Relative linkUrl paths (e.g. /jury/competitions) were passed as-is to
email templates, causing email clients to interpret them as local file
protocols (x-webdoc:// on macOS). Now prepends NEXTAUTH_URL to any
relative path before sending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:45:20 +01:00
Matt
baca483fcb Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
## Critical Logic Fixes (Tier 1)
- Fix requiredReviews config key mismatch (always defaulted to 3)
- Fix double-email + stageName/roundName metadata mismatch in notifications
- Fix snake_case config reads in peer review (peerReviewEnabled was always blocked)
- Add server-side COI check to evaluation submit (was client-only)
- Fix hard-coded feedbackText.min(10) — now uses config values
- Fix binaryDecision corruption in non-binary scoring modes
- Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx
- Fix removeFromRound: now cleans up orphaned Assignment records
- Fix 3-day reminder sending wrong email template (was using 24h template)

## High-Priority Logic Fixes (Tier 2)
- Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED)
- Scope AI assignment job to jury group members (was querying all JURY_MEMBERs)
- Add COI awareness to AI assignment generation
- Enforce requireAllCriteriaScored server-side
- Fix expireIntentsForRound nested transaction (now uses caller's tx)
- Implement notifyOnEntry for advancement path
- Implement notifyOnAdvance (was dead config)
- Fix checkRequirementsAndTransition for SubmissionFileRequirement model

## New Features (Tier 3)
- Add Project to Round: dialog with "Create New" and "From Pool" tabs
- Assignment "By Project" mode: select project → assign multiple jurors
- Backend: project.createAndAssignToRound procedure

## UI/UX Improvements (Tier 4+5)
- Add AlertDialog confirmation to header status dropdown
- Replace native confirm() with AlertDialog in assignments table
- Jury stats card now display-only with "Change" link
- Assignments tab restructured into logical card groups
- Inline-editable round name in header
- Back button shows destination label
- Readiness checklist: green check instead of strikethrough
- Gate assignments tab when no jury group assigned
- Relative time on window stats card
- Toast feedback on date saves
- Disable advance button when no target round
- COI section shows placeholder when empty
- Round position shown as "Round X of Y"
- InlineMemberCap edit icon always visible
- Status badge tooltip with description
- Add REMINDER_3_DAYS email template
- Fix maybeSendEmail to respect notification preferences
- Optimize bulk notification email loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
Matt
ee8b12e59c Fix jury reminders, add notify jurors button, fix checkbox borders, widen assignment modal
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m32s
- Send Reminders button now works: added sendManualReminders() that bypasses
  cron-specific window/deadline/dedup guards so admin can send immediately
- Added Notify Jurors button that sends direct BATCH_ASSIGNED emails to all
  jurors with assignments (not dependent on NotificationEmailSetting config)
- Fixed checkbox component: default border is now neutral grey (border-input),
  red border (border-primary) only applied when checked
- Widened Add Assignment dialog from max-w-2xl to max-w-3xl to prevent overflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:15:51 +01:00
Matt
51e18870b6 Admin UI audit round 2: fix 28 display bugs across 23 files
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
HIGH fixes (broken features / wrong data):
- H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences)
- H2: Fix deliberation results panel blank table (wrong field names)
- H3: Fix deliberation participant names blank (wrong data path)
- H4: Fix awards "Evaluated" stat duplicating "Eligible" count
- H5: Fix cross-round comparison enabled at 1 round (backend requires 2)
- H6: Fix setState during render anti-pattern (6 occurrences)
- H7: Fix round detail jury member count always showing 0
- H8: Remove 4 invalid status values from observer dashboard filter
- H9: Fix filtering progress bar always showing 100%

MEDIUM fixes (misleading display):
- M1: Filter special-award rounds from competition timeline
- M2: Exclude special-award rounds from distinct project count
- M3: Fix MENTORING pipeline node hardcoded "0 mentored"
- M4: Fix DELIB_LOCKED badge using red for success state
- M5: Add status label maps to deliberation session detail
- M6: Humanize deliberation category + tie-break method displays
- M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round"
- M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels
- M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge
- M11: Fix unsent messages showing "Scheduled" instead of "Draft"
- M12: Rename misleading totalEvaluations → totalAssignments
- M13: Rename "Stage" column to "Program" in projects page

LOW fixes (cosmetic / edge-case):
- L1: Use unfiltered rounds array for active round detection
- L2: Use all rounds length for new round sort order
- L3: Filter special-award rounds from header count
- L4: Fix single-underscore replace in award status badges
- L5: Fix score bucket boundary gaps (4.99 dropped between buckets)
- L6: Title-case LIVE_FINAL pipeline metric status
- L7: Fix roundType.replace only replacing first underscore
- L8: Remove duplicate severity sort in smart-actions component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
Matt
ae1685179c Comprehensive admin UI stats audit: fix 16 display bugs
HIGH fixes:
- H1: Competition detail project count no longer double-counts across rounds
- H2: Rounds page header stats use unfiltered round set
- H3: Rounds page "eval" label corrected to "asgn" (assignment count)
- H4: Observer reports project count uses distinct analytics count
- H5: Awards eligibility count filters to only eligible=true (backend)
- H6: Round detail projectCount derived from projectStates for consistency
- H7: Deliberation hasVoted derived from votes array (was always undefined)

MEDIUM fixes:
- M1: Reports page round status badges use correct ROUND_ACTIVE/ROUND_CLOSED enums
- M2: Observer reports badges use ROUND_ prefix instead of stale STAGE_ prefix
- M3: Deliberation list status badges use correct VOTING/TALLYING/RUNOFF enums
- M4: Competition list/detail round count excludes special-award rounds (backend)
- M5: Messages page shows actual recipient count instead of hardcoded "1 user"

LOW fixes:
- L2: Observer analytics jurorCount scoped to round when roundId provided
- L3: Analytics round-scoped project count uses ProjectRoundState not assignments
- L4: JuryGroup delete audit log reports member count (not assignment count)
- L5: Project rankings include unevaluated projects at bottom instead of hiding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:56:09 +01:00
Matt
d117090fca Fix rounds page showing inflated project count
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m29s
The "537 projects" count was summing projectRoundStates across all
rounds, so a project in 3 rounds was counted 3 times. Now queries
distinct projectIds across all competition rounds to show the actual
unique project count (214).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:35:58 +01:00
Matt
099157bf74 Fix project status badges to show counts across all pages
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
The status summary badges (Eligible, Rejected, Assigned, etc.) were
computed from only the current page's projects. Now uses a groupBy
query on the same filters to return statusCounts for all matching
projects across all pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:33:14 +01:00
1308c3ba87 Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Phase 1 — Critical bugs:
- Fix deliberation participant selection (wire jury group query)
- Fix reports "By Round" tab (inline content instead of 404 route)
- Fix messages "Sent History" (add message.sent procedure, wire tab)
- Add missing fields to competition award form (criteriaText, maxRankedPicks)
- Wire LiveControlPanel buttons (cursor, voting, scores)
- Fix ResultLockControls empty snapshot (fetch actual data before lock)
- Fix SubmissionWindowManager losing fields on edit

Phase 2 — Backend fixes:
- Remove write-in-query from specialAward.get
- Fix award eligibility job overwriting manual shortlist overrides
- Fix filtering startJob deleting all prior results (defer cleanup to post-success)
- Tighten access control: protectedProcedure → adminProcedure on 8 procedures
- Add audit logging to deliberation mutations
- Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete

Phase 3 — Auto-refresh:
- Add refetchInterval to 15+ admin pages/components (10s–30s)
- Fix AI job polling: derive speed from job status for all viewers

Phase 4 — Dead code cleanup:
- Delete unused command-palette, pdf-report, admin-page-transition
- Remove dead subItems sidebar code, unused GripVertical import
- Replace redundant isGenerating state with mutation.isPending
- Add Role column to jury members table
- Remove misleading manual mentor assignment stub

Phase 5 — UX improvements:
- Fix rounds page single-competition assumption (add selector)
- Remove raw UUID fallback in deliberation config
- Fix programs page "Stage" → "Round" terminology

Phase 6 — Backend hardening:
- Complete logAudit calls (add prisma, ipAddress, userAgent)
- Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear)
- Batch user.bulkCreate writes (assignments, jury memberships, intents)
- Remove any casts from deliberation service (typed PrismaClient + TransactionClient)
- Fix stale DeliberationStatus enum values blocking build

40 files changed, 1010 insertions(+), 612 deletions(-)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
aa1bf564ee Fix award eligibility FK constraint + add country column to round projects
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m13s
- specialAward.setEligibility: add ensureUserExists() guard and use Prisma
  connect syntax to prevent FK violation on stale session user IDs
- specialAward.confirmShortlist: same ensureUserExists() guard for confirmedBy
- Round projects table: add Country column showing project origin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:47:20 +01:00
Matt
6838b01724 Fix per-juror assignment caps: read correct field + inline edit UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m50s
- Fix bug: AI assignment router read non-existent `(m as any).maxAssignments`
  instead of the actual schema field `m.maxAssignmentsOverride`
- Wire `jurorLimits` record into AI assignment constraints so per-juror
  caps are respected during both AI scoring and algorithmic assignment
- Add inline editable cap in jury members table (click to edit, blur/enter
  to save, empty = no cap / use group default)
- Add inline editable cap badges on round page member list so admins can
  set caps right from the assignment workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:23:54 +01:00
Matt
735b841f4a Rewrite AI assignment to hybrid approach: single AI call + algorithm
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s
Instead of 10 sequential GPT calls (which timeout with GPT-5.1 on 99
projects), use a two-phase approach:

Phase 1 - AI Scoring: ONE API call asks GPT to score each juror's
affinity for all projects, returning a compact preference matrix with
expertise match scores and reasoning.

Phase 2 - Algorithm: Uses AI scores as the preference input to a
balanced assignment algorithm that assigns N reviewers per project,
enforcing even workload distribution, respecting per-juror caps, and
filling coverage gaps.

Benefits:
- Single API call eliminates timeout issues
- AI provides expertise-aware scoring, algorithm ensures balance
- Truncated response handling (JSON repair) for resilience
- Falls back to tag-based algorithm if AI fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:49:41 +01:00
Matt
7c3f041892 Fix AI assignment returning nothing: cap tokens, optimize prompt, show errors
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m32s
- Cap maxTokens at 12000 (was unlimited dynamic calc that could exceed model limits)
- Replace massive EXISTING array with compact CURRENT_JUROR_LOAD counts and
  ALREADY_ASSIGNED per-project map (keeps prompt small across batches)
- Add coverage gap-filler: algorithmically fills projects below required reviews
- Show error state inline on page when AI fails (red banner with message)
- Add server-side logging for debugging assignment flow
- Reduce batch size to 10 projects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:24:16 +01:00
Matt
998ffe3af8 Fix AI assignment: generate multiple reviewers per project
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m32s
Problems:
- GPT only generated 1 reviewer per project despite N being required
- maxTokens (4000) too small for N×projects assignment objects
- No fallback when GPT under-assigned

Fixes:
- System prompt now explicitly explains multiple reviewers per project
  with concrete example showing 3 different juror_ids per project
- User prompt includes REVIEWS_PER_PROJECT, EXPECTED_OUTPUT_SIZE
- maxTokens dynamically calculated: expectedAssignments × 200 + 500
- Reduced batch size from 15 to 10 (fewer projects per GPT call)
- Added fillCoverageGaps() post-processor: algorithmically assigns
  least-loaded jurors to any project below required coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:48:06 +01:00
Matt
6abf962fa0 Fix AI assignment workload imbalance: enforce caps and rebalance
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s
Root cause: batches of 15 projects were processed independently -
GPT didn't see assignments from previous batches, so expert jurors
got assigned 18-22 projects while others got 4-5.

Fixes:
- Track cumulative assignments across batches (feed to each batch)
- Calculate ideal target per juror and communicate to GPT
- Add post-processing rebalancer that enforces hard caps and
  redistributes excess assignments to least-loaded jurors
- Calculate sensible default max cap when not configured
- Reweight prompt: workload balance 50%, expertise 35%, diversity 15%

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:16:55 +01:00
Matt
8bbdc31d17 Remove download button on mobile, keep only Open in New Tab
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m25s
iOS Safari doesn't support programmatic downloads reliably.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:53:12 +01:00
Matt
a212bde51b Warn when jurors lack profile data in AI assignment preview
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s
Shows warning with juror names when they have no expertise tags or bio,
so admin can ask them to onboard before committing assignments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:16:22 +01:00
Matt
7e85348a6d AI shortlist with approve/reject, assignment reasoning, fix review count badge
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Rewrite AIRecommendationsDisplay: show project titles, per-project
  checkboxes, Apply and Mark as Passed button with batch transition
- Show AI jury assignment reasoning directly in rows (not tooltip)
- Fix unassigned projects badge using requiredReviews instead of hardcoded 3
- Add aiParseFiles to EvaluationConfigSchema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:11:20 +01:00
Matt
cab311fbbb Fix advancement targets stripped by Zod, remove redundant save bar
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m32s
- Add general settings fields (startupAdvanceCount, conceptAdvanceCount,
  notifyOnEntry, notifyOnAdvance) to ALL round config schemas, not just
  FilteringConfig. Zod was stripping them on save for other round types.
- Replace floating save bar with error-only bar since autosave handles
  all config persistence (800ms debounce)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:59:23 +01:00
Matt
9c19661400 Fix iOS download via Content-Disposition header, fix COI gate null check
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Server: presigned GET URLs now include Content-Disposition: attachment header
  when forDownload=true, triggering native browser downloads on all platforms
- Download button uses window.location.href with attachment URL (works on iOS Safari)
- Bulk download uses hidden iframes instead of fetch+blob
- Fix COI gate: getCOIStatus returns null (not undefined) when undeclared,
  so `!== undefined` was always true — changed to `!= null`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:56:09 +01:00
Matt
8d28104d51 COI gate + admin review, mobile file viewer fixes for iOS
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s
- Integrate COI declaration dialog into jury evaluate page (blocks evaluation until declared)
- Add COI review section to admin round page with clear/reassign/note actions
- Fix mobile: remove inline preview (viewport too small), add labeled buttons
- Fix iOS: open-in-new-tab uses synchronous window.open to avoid popup blocker
- Fix iOS: download falls back to direct link if fetch+blob fails (CORS/Safari)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:34:27 +01:00
Matt
0f6473c999 Jury inline doc preview, download fix, category tags, admin eval reset
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m55s
- Replace MultiWindowDocViewer with FileViewer for inline previews (PDF/image/video/Office)
- Fix cross-origin download using fetch+blob instead of <a download>
- Show Startup/Business Concept badge on jury project detail + evaluate pages
- Add admin resetEvaluation procedure with audit logging
- Add dropdown menu on admin assignment rows with Reset Evaluation + Delete
- Make file action buttons responsive on mobile (separate row below file info)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:03:38 +01:00
Matt
9ce56f13fd Jury evaluation UX overhaul + admin review features
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
- Fix project documents not displaying on jury project page (rewrote MultiWindowDocViewer to use file.listByProject)
- Add working download/preview for project files via presigned URLs
- Display project tags on jury project detail page
- Add autosave for evaluation drafts (debounced 3s + save on unmount/beforeunload)
- Support mixed criterion types: numeric scores, yes/no booleans, text responses, section headers
- Replace inline criteria editor with rich EvaluationFormBuilder on admin round page
- Remove COI dialog from evaluation page
- Update AI summary service to handle boolean/text criteria (yes/no counts, text synthesis)
- Update EvaluationSummaryCard to show boolean criteria bars and text responses
- Add evaluation detail sheet on admin project page (click juror row to view full scores + feedback)
- Add Recent Evaluations dashboard widget showing latest jury reviews

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:43:28 +01:00
Matt
73759eaddd Trigger rebuild
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m8s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:52:28 +01:00
Matt
f814cf6dc4 Move round scheduler in-app via instrumentation.ts, remove cron endpoint
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s
Round open/close scheduling now runs as a 60s setInterval inside the
app process (via instrumentation.ts register hook) instead of needing
an external crontab. Removed the /api/cron/round-scheduler endpoint.

- DRAFT rounds auto-activate when windowOpenAt arrives
- ACTIVE rounds auto-close when windowCloseAt passes
- Uses existing activateRound/closeRound from round-engine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:35:28 +01:00
Matt
9b1b319362 Add cron endpoint for automatic round open/close scheduling
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
New /api/cron/round-scheduler endpoint that:
- Activates DRAFT rounds whose windowOpenAt has arrived
- Closes ACTIVE rounds whose windowCloseAt has passed
- Uses existing activateRound/closeRound from round-engine
- Protected by CRON_SECRET header like other cron endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:33:25 +01:00
Matt
7b16873b9c Fix finalize to actually advance passed projects to next round
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
finalizeResults was finding nextRound but never creating
ProjectRoundState entries — projects got set to ELIGIBLE but
were not placed into the next round. Now creates entries with
skipDuplicates so it's safe to re-run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:31:13 +01:00
Matt
fc7a37094b Exclude SEPARATE_POOL award projects from main pool finalization
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m47s
- finalizeResults now queries confirmed SEPARATE_POOL shortlist and
  excludes those projects from the main pool ELIGIBLE set
- Finalize confirmation dialog shows breakdown: main pool vs award-routed
- Finalize toast includes award-routed count
- Audit log records routedToAwards count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:28:45 +01:00
Matt
35f30af7ce Show award-routed projects in filtering stats and results table
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Stats cards show new 'Award Track' card with count of confirmed
  SEPARATE_POOL shortlisted projects
- Passed card shows breakdown (main + award) when awards are routed
- Results table shows award badge on projects routed to award tracks
- getResults query includes confirmed award eligibility data per project

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:25:47 +01:00
Matt
6e9fcda45a Fix stale session redirect loop, filtering stats to reflect overrides
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m56s
- Auth layout verifies user exists in DB before redirecting to dashboard,
  breaking infinite loop for deleted accounts with stale sessions
- Jury/Mentor layouts handle null user (deleted) by redirecting to login
- Filtering stats cards and result list now use effective outcome
  (finalOutcome ?? outcome) instead of raw AI outcome
- Award eligibility evaluation includes admin-overridden PASSED projects
- Award shortlist reasoning column shows full text instead of truncating

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:01:31 +01:00
1ec2247295 Make selected expertise tags compact in onboarding
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m17s
Reduced badge size (text-[11px], h-5, tighter padding/gaps) and capped
the selected tags container to max-h-20 with overflow scroll so they
no longer push the rest of the form off-screen on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:34:51 +01:00
1c68512598 Add built-in hard reject for projects with zero uploaded files
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Projects with no files are now automatically rejected before AI
screening runs, regardless of whether a DOCUMENT_CHECK rule is
configured. This prevents the AI from incorrectly passing projects
that have no supporting documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:31:46 +01:00
04c54b6794 Fix FK constraint error on filtering override — verify user exists
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
The overriddenBy FK to User was failing when the session contained a
stale user ID (e.g. after database reseed). Added ensureUserExists()
guard to all override/reinstate mutations and switched single-record
updates to use Prisma connect syntax for safer FK resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:23:16 +01:00
d02b0b91b9 Award shortlist UX improvements + configurable invite link expiry
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s
Award shortlist:
- Expandable reasoning text (click to toggle, hover hint)
- Bulk select/deselect all checkbox in header
- Top N projects highlighted with amber background
- New bulkToggleShortlisted backend mutation

Invite link expiry:
- New "Invitation Link Expiry (hours)" field in Security Settings
- Reads from systemSettings `invite_link_expiry_hours` (default 72h / 3 days)
- Email template dynamically shows "X hours" or "X days" based on setting
- All 3 invite paths (bulk create, single invite, bulk resend) use setting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:05:58 +01:00
8a7da0fd93 Fix standalone award eligibility to send rich project data matching filtering pass
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m39s
The Re-evaluate button was producing fewer eligible results because
the standalone job sent minimal project data (title, description, tags)
while the integrated filtering pass sent full data (files, team, institution).

- Fetch rich project data in award-eligibility-job (files, team, institution, etc.)
- Relax AI prompt to be inclusive like the integrated pass — strong primary
  criterion fit is sufficient, don't require all dimensions above 0.5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:34:30 +01:00
70d24036f9 Fix award source round dropdown — auto-resolve competitionId from program
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m19s
Awards created from /admin/awards/new only sent programId, leaving
competitionId null. The edit page's source round dropdown was empty
because it depended on competitionId to fetch competition rounds.

- create mutation: auto-resolve competitionId from program's latest competition
- update mutation: backfill competitionId on save if missing
- get query: backfill competitionId on read for legacy awards
- edit page: use award.competition.rounds directly instead of separate query

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:00:20 +01:00
619206c03f Integrate special award eligibility into AI filtering pass
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
Single AI call now evaluates both screening criteria AND award eligibility.
Awards with useAiEligibility + criteriaText are appended to the system prompt,
AI returns award_matches per project, results auto-populate AwardEligibility
and auto-shortlist top-N. Re-running filtering clears and re-evaluates awards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:32:38 +01:00
1fe6667400 Special awards: Rounds tab UI, auto-filter threshold, remove auto-tag rules
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
- Add Rounds tab to award detail page with create/list/delete functionality
- Add "Entry point" badge on first award round (confirmShortlist routes here)
- Fix round detail back-link to navigate to parent award when specialAwardId set
- Filter award rounds out of competition round list
- Add specialAwardId to competition getById round select
- Warn on confirmShortlist when no award rounds exist (SEPARATE_POOL mode)
- Remove auto-tag rules from award config, edit page, router, and AI service
- Fix competitionId not passed when creating awards from competition context
- Add AUTO_FILTER quality threshold to AI filtering dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 19:53:20 +01:00
Matt
4fa3ca0bb6 Fix config save state sync — local config now re-syncs after save
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m46s
The save bar persisted forever because Zod.parse() adds defaults for
new fields, making the server config differ from local state. After
save, the sync effect now picks up the server value. Uses savingRef
to prevent overwriting local edits during the save roundtrip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:53:51 +01:00
Matt
cf1508f856 Fix filtering config save, auto-save, streamed results, improved AI prompt
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
- Add missing fields to FilteringConfigSchema (aiParseFiles, startupAdvanceCount,
  conceptAdvanceCount, notifyOnEntry, notifyOnAdvance) — Zod was silently
  stripping them on save
- Restore auto-save with 800ms debounce on config changes
- Add staggered animations for filtering results (stream in one-by-one)
- Improve AI screening prompt: file type label mappings, soft cap handling,
  missing documents = fail, better user prompt structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:18:04 +01:00
Matt
bed444e5f4 Move AI document parsing toggle from Config to Filtering tab
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m9s
Removes duplicate: the setting was in Config tab (General Settings) AND
inside Advanced Settings in the Filtering tab. Now it lives only in the
Filtering tab as a prominent standalone toggle, since it's directly
related to AI filtering behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:51:23 +01:00
Matt
a4ff278db2 Manual save button, file requirement labels, fix config revert bug
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Replace auto-save with manual floating save bar that appears when config
  has unsaved changes (Discard / Save Changes buttons). Fixes race condition
  where server sync overwrote local state after toggling switches.
- Show file requirement name (e.g. "Pitch Deck", "Presentation") above each
  document in the All Uploaded Files section on project detail page
- Pass requirement relation data through to FileViewer component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:43:47 +01:00
Matt
1c6961355b Filtering UX: overview results, auto-clear on re-run, config save fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m25s
- Add filtering results summary card on round Overview tab with pass/fail/flag
  counts and color-coded progress bar (polls every 5s)
- Auto-delete previous filtering results when re-running so new ones stream in
- Rename BUSINESS_CONCEPT to "Concept" in filtering results to prevent overflow
- Fix config save race condition where toggling switches (aiParseFiles, advance
  counts) would revert: pendingSaveRef cleared before refetch completed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:25:59 +01:00
Matt
a02ed59158 Fix AI filtering bugs, add special award shortlist integration
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)

Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
  listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
Matt
6743119c4d AI-powered assignment generation with enriched data and streaming UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m19s
- Add aiPreview mutation with full project/juror data (bios, descriptions,
  documents, categories, ocean issues, countries, team sizes)
- Increase AI description limit from 300 to 2000 chars for richer context
- Update GPT system prompt to use all available data fields
- Add mode toggle (AI default / Algorithm fallback) in assignment preview
- Lift AI mutation to parent page for background generation persistence
- Show visual indicator on page while AI generates (spinner + progress card)
- Toast notification with "Review" action when AI completes
- Staggered reveal animation for assignment results (streaming feel)
- Fix assignment balance with dynamic penalty (25pts per existing assignment)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:45:57 +01:00
Matt
a7b6031f4d Redesign assignment preview with detailed editable juror-project view
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m40s
Replace bare summary stats with full interactive assignment preview:
- Assignments grouped by juror with collapsible cards
- Per-assignment detail: match score, tags, reasoning, policy warnings
- Remove individual assignments with hover X button
- Inline add projects per juror + global juror/project picker
- No cap enforcement on manual adds (admin override)
- Track manual additions and removals with badge indicators
- Include user details in round.getById jury group members query

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:27:01 +01:00
Matt
a62f511d7f Fix voting gate to use round status, make eval doc uploads toggleable
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m26s
Voting check now uses round.status === ROUND_ACTIVE instead of requiring
windowOpenAt/windowCloseAt date range, fixing manual open/reopen scenarios.
Added requireDocumentUpload toggle (default off) to evaluation round config
so rounds reusing prior-round documents don't need file requirements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:13:25 +01:00
Matt
cef4709444 Auto-refresh jury dashboard every 30s for live round updates
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m40s
Add AutoRefresh client component that calls router.refresh() on an
interval. Pauses when tab is hidden and refreshes immediately when
tab becomes visible again. Jury dashboard now reflects round
activations within seconds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:57:40 +01:00
Matt
cf3c7631cb Auto-close preceding active rounds when closing a later round
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
When closing round N, any active rounds with lower sortOrder in the
same competition are automatically closed. Each cascade closure is
recorded in DecisionAuditLog with closedBy: 'cascade' reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:55:44 +01:00
Matt
b3b3bbb8b3 Fix mobile overflow, logo nav, round activation, compare projects setting
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Fix assignments page header overflow on mobile (flex-wrap)
- Hide 'Back to Dashboard' button on mobile (logo tap navigates home)
- Make logo/brand text clickable to navigate to role dashboard
- Snap windowOpenAt to now when manually activating a round early
- Gate Compare Projects cards behind jury_compare_enabled setting (defaults off)
- Expose jury_compare_enabled in getFeatureFlags tRPC procedure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:48:12 +01:00
Matt
bfdbd0fc6a Jury UX: fix COI modal, add sliders, redesign stats, gate evaluations
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
- Switch COI dialog from Dialog to AlertDialog (non-dismissible)
- Replace number inputs with sliders + rating buttons for criteria/global scores
- Redesign jury dashboard stat cards: compact strip on mobile, editorial grid on desktop
- Remove ROUND_ACTIVE filter from myAssignments so all assignments show
- Block evaluate page when round is inactive or voting window is closed
- Gate evaluate button on project detail page based on voting window status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:07:40 +01:00
177 changed files with 20469 additions and 6609 deletions

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -11,7 +11,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* .npmrc* ./
RUN npm ci RUN npm ci
# Rebuild the source code only when needed # Rebuild the source code only when needed

View File

@@ -10,7 +10,7 @@ WORKDIR /app
RUN apk add --no-cache libc6-compat openssl RUN apk add --no-cache libc6-compat openssl
# Copy package files # Copy package files
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* .npmrc* ./
# Install dependencies # Install dependencies
RUN npm install && npm install tailwindcss-animate RUN npm install && npm install tailwindcss-animate

590
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "mopc-platform", "name": "mopc-platform",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2", "@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2", "@blocknote/mantine": "^0.46.2",
@@ -37,9 +38,11 @@
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@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",
@@ -72,7 +75,6 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-phone-number-input": "^3.4.14", "react-phone-number-input": "^3.4.14",
"recharts": "^3.7.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.2", "superjson": "^2.2.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
@@ -118,6 +120,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.78.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz",
"integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@auth/core": { "node_modules/@auth/core": {
"version": "0.41.1", "version": "0.41.1",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
@@ -1024,6 +1046,40 @@
"prosemirror-view": "^1.0.0" "prosemirror-view": "^1.0.0"
} }
}, },
"node_modules/@headlessui/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz",
"integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.17.1",
"@react-aria/interactions": "^3.21.3",
"@tanstack/react-virtual": "^3.8.1"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@headlessui/react/node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
@@ -1624,16 +1680,6 @@
"react": "^18.x || ^19.x" "react": "^18.x || ^19.x"
} }
}, },
"node_modules/@mantine/utils": {
"version": "6.0.22",
"resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.22.tgz",
"integrity": "sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@napi-rs/canvas": { "node_modules/@napi-rs/canvas": {
"version": "0.1.80", "version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
@@ -2048,7 +2094,7 @@
"version": "1.58.0", "version": "1.58.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.58.0" "playwright": "1.58.0"
@@ -2086,7 +2132,7 @@
"version": "6.19.2", "version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz",
"integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"c12": "3.1.0", "c12": "3.1.0",
@@ -2099,14 +2145,14 @@
"version": "6.19.2", "version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz",
"integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==",
"devOptional": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.19.2", "version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz",
"integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==",
"devOptional": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -2120,14 +2166,14 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"devOptional": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.19.2", "version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz",
"integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.19.2", "@prisma/debug": "6.19.2",
@@ -2139,7 +2185,7 @@
"version": "6.19.2", "version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz",
"integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.19.2" "@prisma/debug": "6.19.2"
@@ -3261,6 +3307,31 @@
} }
} }
}, },
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
@@ -3496,6 +3567,73 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-aria/focus": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.4.tgz",
"integrity": "sha512-6gz+j9ip0/vFRTKJMl3R30MHopn4i19HqqLfSQfElxJD+r9hBnYG1Q6Wd/kl/WRR1+CALn2F+rn06jUnf5sT8Q==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/interactions": "^3.27.0",
"@react-aria/utils": "^3.33.0",
"@react-types/shared": "^3.33.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.0.tgz",
"integrity": "sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-aria/utils": "^3.33.0",
"@react-stately/flags": "^3.1.2",
"@react-types/shared": "^3.33.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/utils": {
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.0.tgz",
"integrity": "sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-stately/flags": "^3.1.2",
"@react-stately/utils": "^3.11.0",
"@react-types/shared": "^3.33.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-leaflet/core": { "node_modules/@react-leaflet/core": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
@@ -3507,40 +3645,34 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
} }
}, },
"node_modules/@reduxjs/toolkit": { "node_modules/@react-stately/flags": {
"version": "2.11.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
"license": "MIT", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@swc/helpers": "^0.5.0"
"@standard-schema/utils": "^0.3.0", }
"immer": "^11.0.0", },
"redux": "^5.0.1", "node_modules/@react-stately/utils": {
"redux-thunk": "^3.1.0", "version": "3.11.0",
"reselect": "^5.1.0" "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz",
"integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
} }
}, },
"node_modules/@reduxjs/toolkit/node_modules/immer": { "node_modules/@react-types/shared": {
"version": "11.1.3", "version": "3.33.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.0.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", "integrity": "sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==",
"license": "MIT", "license": "Apache-2.0",
"funding": { "peerDependencies": {
"type": "opencollective", "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
"url": "https://opencollective.com/immer"
} }
}, },
"node_modules/@remirror/core-constants": { "node_modules/@remirror/core-constants": {
@@ -3933,12 +4065,7 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "dev": true,
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
@@ -4250,6 +4377,23 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/store": { "node_modules/@tanstack/store": {
"version": "0.7.7", "version": "0.7.7",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz", "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz",
@@ -4260,6 +4404,16 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/virtual-core": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tiptap/core": { "node_modules/@tiptap/core": {
"version": "3.18.0", "version": "3.18.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.18.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.18.0.tgz",
@@ -4500,6 +4654,87 @@
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@tremor/react": {
"version": "3.18.7",
"resolved": "https://registry.npmjs.org/@tremor/react/-/react-3.18.7.tgz",
"integrity": "sha512-nmqvf/1m0GB4LXc7v2ftdfSLoZhy5WLrhV6HNf0SOriE6/l8WkYeWuhQq8QsBjRi94mUIKLJ/VC3/Y/pj6VubQ==",
"license": "Apache 2.0",
"dependencies": {
"@floating-ui/react": "^0.19.2",
"@headlessui/react": "2.2.0",
"date-fns": "^3.6.0",
"react-day-picker": "^8.10.1",
"react-transition-state": "^2.1.2",
"recharts": "^2.13.3",
"tailwind-merge": "^2.5.2"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/@tremor/react/node_modules/@floating-ui/react": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.2.tgz",
"integrity": "sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^1.3.0",
"aria-hidden": "^1.1.3",
"tabbable": "^6.0.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@tremor/react/node_modules/@floating-ui/react-dom": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz",
"integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.2.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@tremor/react/node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/@tremor/react/node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@tremor/react/node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/@trpc/client": { "node_modules/@trpc/client": {
"version": "11.9.0", "version": "11.9.0",
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.9.0.tgz", "resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.9.0.tgz",
@@ -4799,6 +5034,7 @@
"version": "19.2.10", "version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -4808,6 +5044,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -5921,7 +6158,7 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@@ -6114,7 +6351,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
@@ -6130,7 +6367,7 @@
"version": "0.1.6", "version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"consola": "^3.2.3" "consola": "^3.2.3"
@@ -6248,14 +6485,14 @@
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/consola": { "node_modules/consola": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^14.18.0 || >=16.10.0" "node": "^14.18.0 || >=16.10.0"
@@ -6596,7 +6833,7 @@
"version": "7.1.5", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@@ -6641,7 +6878,7 @@
"version": "6.1.4", "version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
@@ -6666,7 +6903,7 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
@@ -6716,6 +6953,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
@@ -6730,7 +6977,7 @@
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -6766,7 +7013,7 @@
"version": "3.18.4", "version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
@@ -6790,7 +7037,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@@ -7001,16 +7248,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.2", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -7520,7 +7757,7 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/extend": { "node_modules/extend": {
@@ -7533,7 +7770,7 @@
"version": "3.23.2", "version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true, "dev": true,
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -7963,7 +8200,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"citty": "^0.1.6", "citty": "^0.1.6",
@@ -8444,16 +8681,6 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -9071,6 +9298,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -10857,7 +11097,7 @@
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
@@ -10879,7 +11119,7 @@
"version": "0.6.4", "version": "0.6.4",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz",
"integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==", "integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"citty": "^0.2.0", "citty": "^0.2.0",
@@ -10897,7 +11137,7 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz",
"integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==", "integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/oauth4webapi": { "node_modules/oauth4webapi": {
@@ -11046,7 +11286,7 @@
"version": "2.0.11", "version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/openai": { "node_modules/openai": {
@@ -11239,7 +11479,7 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-parse": { "node_modules/pdf-parse": {
@@ -11278,7 +11518,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/performance-now": { "node_modules/performance-now": {
@@ -11311,7 +11551,7 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"confbox": "^0.2.2", "confbox": "^0.2.2",
@@ -11323,7 +11563,7 @@
"version": "1.58.0", "version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.58.0" "playwright-core": "1.58.0"
@@ -11342,7 +11582,7 @@
"version": "1.58.0", "version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@@ -11516,7 +11756,7 @@
"version": "6.19.2", "version": "6.19.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
"integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==",
"devOptional": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -11836,7 +12076,7 @@
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true, "dev": true,
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -11902,7 +12142,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"defu": "^6.1.4", "defu": "^6.1.4",
@@ -12037,29 +12277,6 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -12107,6 +12324,21 @@
} }
} }
}, },
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -12146,6 +12378,32 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/react-transition-state": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/react-transition-state/-/react-transition-state-2.3.3.tgz",
"integrity": "sha512-wsIyg07ohlWEAYDZHvuXh/DY7mxlcLb0iqVv2aMXJ0gwgPVKNWKhOyNyzuJy/tt/6urSq0WT6BBZ/tdpybaAsQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -12164,7 +12422,7 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14.18.0" "node": ">= 14.18.0"
@@ -12175,49 +12433,48 @@
} }
}, },
"node_modules/recharts": { "node_modules/recharts": {
"version": "3.7.0", "version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT", "license": "MIT",
"workspaces": [
"www"
],
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.0.0",
"clsx": "^2.1.1", "eventemitter3": "^4.0.1",
"decimal.js-light": "^2.5.1", "lodash": "^4.17.21",
"es-toolkit": "^1.39.3", "react-is": "^18.3.1",
"eventemitter3": "^5.0.1", "react-smooth": "^4.0.4",
"immer": "^10.1.1", "recharts-scale": "^0.4.4",
"react-redux": "8.x.x || 9.x.x", "tiny-invariant": "^1.3.1",
"reselect": "5.1.1", "victory-vendor": "^36.6.8"
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=14"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/redux": { "node_modules/recharts-scale": {
"version": "5.0.1", "version": "0.4.5",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/redux-thunk": { "node_modules/recharts/node_modules/react-is": {
"version": "3.1.0", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT", "license": "MIT"
"peerDependencies": {
"redux": "^5.0.0"
}
}, },
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
@@ -12411,12 +12668,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -13296,7 +13547,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -13392,6 +13643,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -13566,6 +13823,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -13961,9 +14219,9 @@
} }
}, },
"node_modules/victory-vendor": { "node_modules/victory-vendor": {
"version": "37.3.6", "version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC", "license": "MIT AND ISC",
"dependencies": { "dependencies": {
"@types/d3-array": "^3.0.3", "@types/d3-array": "^3.0.3",

View File

@@ -21,6 +21,7 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2", "@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2", "@blocknote/mantine": "^0.46.2",
@@ -50,9 +51,11 @@
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@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",
@@ -85,7 +88,6 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-phone-number-input": "^3.4.14", "react-phone-number-input": "^3.4.14",
"recharts": "^3.7.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.2", "superjson": "^2.2.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",

View File

@@ -0,0 +1,20 @@
-- AlterTable: Add shortlistSize to SpecialAward
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "shortlistSize" INTEGER NOT NULL DEFAULT 10;
-- AlterTable: Add qualityScore, shortlisted, confirmedAt, confirmedBy to AwardEligibility
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "qualityScore" DOUBLE PRECISION;
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "shortlisted" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedAt" TIMESTAMP(3);
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedBy" TEXT;
-- AlterTable: Add specialAwardId to Round
ALTER TABLE "Round" ADD COLUMN IF NOT EXISTS "specialAwardId" TEXT;
-- AddForeignKey: AwardEligibility.confirmedBy -> User.id
ALTER TABLE "AwardEligibility" ADD CONSTRAINT "AwardEligibility_confirmedBy_fkey" FOREIGN KEY ("confirmedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: Round.specialAwardId -> SpecialAward.id
ALTER TABLE "Round" ADD CONSTRAINT "Round_specialAwardId_fkey" FOREIGN KEY ("specialAwardId") REFERENCES "SpecialAward"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Round_specialAwardId_idx" ON "Round"("specialAwardId");

View File

@@ -0,0 +1,8 @@
-- Add isTest field to User, Program, Project, Competition for test environment isolation
ALTER TABLE "User" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Program" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Project" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Competition" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
-- Index for efficient test data filtering
CREATE INDEX "Competition_isTest_idx" ON "Competition"("isTest");

View File

@@ -0,0 +1,13 @@
-- Delete any existing LOCALIZATION settings
DELETE FROM "SystemSettings" WHERE category = 'LOCALIZATION';
-- Add provider field to AIUsageLog for cross-provider cost tracking
ALTER TABLE "AIUsageLog" ADD COLUMN "provider" TEXT;
-- Remove LOCALIZATION from SettingCategory enum
-- First create new enum without the value, then swap
CREATE TYPE "SettingCategory_new" AS ENUM ('AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP', 'AUDIT_CONFIG', 'DIGEST', 'ANALYTICS', 'INTEGRATIONS', 'COMMUNICATION', 'FEATURE_FLAGS');
ALTER TABLE "SystemSettings" ALTER COLUMN "category" TYPE "SettingCategory_new" USING ("category"::text::"SettingCategory_new");
ALTER TYPE "SettingCategory" RENAME TO "SettingCategory_old";
ALTER TYPE "SettingCategory_new" RENAME TO "SettingCategory";
DROP TYPE "SettingCategory_old";

View File

@@ -101,7 +101,6 @@ enum SettingCategory {
DEFAULTS DEFAULTS
WHATSAPP WHATSAPP
AUDIT_CONFIG AUDIT_CONFIG
LOCALIZATION
DIGEST DIGEST
ANALYTICS ANALYTICS
INTEGRATIONS INTEGRATIONS
@@ -351,6 +350,9 @@ model User {
preferredWorkload Int? preferredWorkload Int?
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string } availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
lastLoginAt DateTime? lastLoginAt DateTime?
@@ -379,6 +381,7 @@ model User {
// Award overrides // Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
// In-app notifications // In-app notifications
@@ -494,6 +497,9 @@ model Program {
description String? description String?
settingsJson Json? @db.JsonB settingsJson Json? @db.JsonB
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -618,6 +624,9 @@ model Project {
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc. metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc. externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -906,7 +915,8 @@ model AIUsageLog {
entityId String? entityId String?
// What was used // What was used
model String // gpt-4o, gpt-4o-mini, o1, etc. model String // gpt-4o, gpt-4o-mini, o1, claude-sonnet-4-5, etc.
provider String? // openai, anthropic, litellm
promptTokens Int promptTokens Int
completionTokens Int completionTokens Int
totalTokens Int totalTokens Int
@@ -1507,6 +1517,7 @@ model SpecialAward {
juryGroupId String? juryGroupId String?
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN) eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION" decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
shortlistSize Int @default(10)
// Eligibility job tracking // Eligibility job tracking
eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED
@@ -1530,6 +1541,7 @@ model SpecialAward {
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull) competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull) evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
rounds Round[] @relation("AwardRounds")
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@@ -1545,11 +1557,17 @@ model AwardEligibility {
method EligibilityMethod @default(AUTO) method EligibilityMethod @default(AUTO)
eligible Boolean @default(false) eligible Boolean @default(false)
aiReasoningJson Json? @db.JsonB aiReasoningJson Json? @db.JsonB
qualityScore Float?
shortlisted Boolean @default(false)
// Admin override // Admin override
overriddenBy String? overriddenBy String?
overriddenAt DateTime? overriddenAt DateTime?
// Shortlist confirmation
confirmedAt DateTime?
confirmedBy String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1557,6 +1575,7 @@ model AwardEligibility {
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull) overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
confirmer User? @relation("AwardEligibilityConfirmer", fields: [confirmedBy], references: [id], onDelete: SetNull)
@@unique([awardId, projectId]) @@unique([awardId, projectId])
@@index([awardId]) @@index([awardId])
@@ -2080,6 +2099,9 @@ model Competition {
notifyOnDeadlineApproach Boolean @default(true) notifyOnDeadlineApproach Boolean @default(true)
deadlineReminderDays Int[] @default([7, 3, 1]) deadlineReminderDays Int[] @default([7, 3, 1])
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2094,6 +2116,7 @@ model Competition {
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@@index([isTest])
} }
model Round { model Round {
@@ -2118,12 +2141,14 @@ model Round {
// Links to other entities // Links to other entities
juryGroupId String? juryGroupId String?
submissionWindowId String? submissionWindowId String?
specialAwardId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull) submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
projectRoundStates ProjectRoundState[] projectRoundStates ProjectRoundState[]
@@ -2157,6 +2182,7 @@ model Round {
@@index([competitionId]) @@index([competitionId])
@@index([roundType]) @@index([roundType])
@@index([status]) @@index([status])
@@index([specialAwardId])
} }
model ProjectRoundState { model ProjectRoundState {

View File

@@ -131,7 +131,7 @@ async function main() {
const existingTags = await prisma.expertiseTag.findMany({ const existingTags = await prisma.expertiseTag.findMany({
select: { name: true }, select: { name: true },
}) })
const existingNames = new Set(existingTags.map((t) => t.name)) const existingNames = new Set(existingTags.map((t: { name: string }) => t.name))
// Filter out tags that already exist // Filter out tags that already exist
const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name)) const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name))

View File

@@ -83,6 +83,11 @@ const ACTION_TYPES = [
'ROLE_CHANGED', 'ROLE_CHANGED',
'PASSWORD_SET', 'PASSWORD_SET',
'PASSWORD_CHANGED', 'PASSWORD_CHANGED',
'JUROR_DROPOUT_RESHUFFLE',
'COI_REASSIGNMENT',
'APPLY_AI_SUGGESTIONS',
'APPLY_SUGGESTIONS',
'NOTIFY_JURORS_OF_ASSIGNMENTS',
] ]
// Entity type options // Entity type options
@@ -118,6 +123,11 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
ROLE_CHANGED: 'secondary', ROLE_CHANGED: 'secondary',
PASSWORD_SET: 'outline', PASSWORD_SET: 'outline',
PASSWORD_CHANGED: 'outline', PASSWORD_CHANGED: 'outline',
JUROR_DROPOUT_RESHUFFLE: 'destructive',
COI_REASSIGNMENT: 'secondary',
APPLY_AI_SUGGESTIONS: 'default',
APPLY_SUGGESTIONS: 'default',
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
} }
export default function AuditLogPage() { export default function AuditLogPage() {
@@ -151,7 +161,7 @@ export default function AuditLogPage() {
) )
// Fetch audit logs // Fetch audit logs
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput) const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput, { refetchInterval: 30_000 })
// Fetch users for filter dropdown // Fetch users for filter dropdown
const { data: usersData } = trpc.user.list.useQuery({ const { data: usersData } = trpc.user.list.useQuery({
@@ -516,9 +526,15 @@ export default function AuditLogPage() {
<p className="text-xs font-medium text-muted-foreground mb-1"> <p className="text-xs font-medium text-muted-foreground mb-1">
Details Details
</p> </p>
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
) : log.action === 'COI_REASSIGNMENT' ? (
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
) : (
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto"> <pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)} {JSON.stringify(log.detailsJson, null, 2)}
</pre> </pre>
)}
</div> </div>
)} )}
{!!(log as Record<string, unknown>).previousDataJson && ( {!!(log as Record<string, unknown>).previousDataJson && (
@@ -622,9 +638,15 @@ export default function AuditLogPage() {
<p className="text-xs font-medium text-muted-foreground mb-1"> <p className="text-xs font-medium text-muted-foreground mb-1">
Details Details
</p> </p>
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
) : log.action === 'COI_REASSIGNMENT' ? (
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
) : (
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto"> <pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)} {JSON.stringify(log.detailsJson, null, 2)}
</pre> </pre>
)}
</div> </div>
)} )}
</div> </div>
@@ -693,6 +715,129 @@ export default function AuditLogPage() {
) )
} }
function ReshuffleDetailView({ details }: { details: Record<string, unknown> }) {
const reassignedTo = (details.reassignedTo ?? {}) as Record<string, number>
const jurorIds = Object.keys(reassignedTo)
const moves = (details.moves ?? []) as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]
// Resolve juror IDs to names
const { data: nameMap } = trpc.user.resolveNames.useQuery(
{ ids: [...jurorIds, details.droppedJurorId as string].filter(Boolean) },
{ enabled: jurorIds.length > 0 },
)
const droppedName = (details.droppedJurorName as string) || (nameMap && details.droppedJurorId ? nameMap[details.droppedJurorId as string] : null) || (details.droppedJurorId as string)
return (
<div className="rounded-lg border bg-white overflow-hidden text-sm">
{/* Summary */}
<div className="p-3 bg-muted/50 border-b space-y-1">
<div className="flex items-center gap-2">
<Badge variant="destructive">Juror Dropout</Badge>
<span className="font-semibold">{droppedName}</span>
</div>
<p className="text-xs text-muted-foreground">
{String(details.movedCount)} project(s) reassigned, {String(details.failedCount)} failed
{details.removedFromGroup ? ' — removed from jury group' : ''}
</p>
</div>
{/* Per-project moves (new format) */}
{moves.length > 0 && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-2">Project New Juror</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Project</th>
<th className="text-left py-1 font-medium">Reassigned To</th>
</tr>
</thead>
<tbody>
{moves.map((move, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1.5 pr-2">{move.projectTitle}</td>
<td className="py-1.5 font-medium">{move.newJurorName}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Fallback: count-based view (old format, no per-project detail) */}
{moves.length === 0 && jurorIds.length > 0 && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-2">Reassignment Summary (project detail not available)</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Juror</th>
<th className="text-right py-1 font-medium">Projects Received</th>
</tr>
</thead>
<tbody>
{jurorIds.map((id) => (
<tr key={id} className="border-b last:border-0">
<td className="py-1.5">{nameMap?.[id] || id}</td>
<td className="py-1.5 text-right font-medium">{reassignedTo[id]}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Failed projects */}
{Array.isArray(details.failedProjects) && (details.failedProjects as string[]).length > 0 && (
<div className="p-3 border-t bg-red-50/50">
<p className="text-xs font-medium text-red-700 mb-1">Could not reassign:</p>
<ul className="text-xs text-muted-foreground list-disc list-inside">
{(details.failedProjects as string[]).map((p, i) => (
<li key={i}>{p}</li>
))}
</ul>
</div>
)}
</div>
)
}
function COIReassignmentDetailView({ details }: { details: Record<string, unknown> }) {
const ids = [details.oldJurorId, details.newJurorId].filter(Boolean) as string[]
const { data: nameMap } = trpc.user.resolveNames.useQuery(
{ ids },
{ enabled: ids.length > 0 },
)
const oldName = nameMap?.[details.oldJurorId as string] || (details.oldJurorId as string)
const newName = nameMap?.[details.newJurorId as string] || (details.newJurorId as string)
return (
<div className="rounded-lg border bg-white overflow-hidden text-sm">
<div className="p-3 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">COI Reassignment</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<p className="text-muted-foreground">From</p>
<p className="font-medium">{oldName}</p>
</div>
<div>
<p className="text-muted-foreground">To</p>
<p className="font-medium">{newName}</p>
</div>
</div>
<div className="text-xs text-muted-foreground">
Project: <span className="font-mono">{(details.projectId as string)?.slice(0, 12)}...</span>
{' | '}Round: <span className="font-mono">{(details.roundId as string)?.slice(0, 12)}...</span>
</div>
</div>
</div>
)
}
function DiffViewer({ before, after }: { before: unknown; after: unknown }) { function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {} const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {}
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {} const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}

View File

@@ -25,15 +25,7 @@ import {
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ArrowLeft, Save, Loader2, Plus, X, Info } from 'lucide-react' import { ArrowLeft, Save, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
type AutoTagRule = {
id: string
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
operator: 'equals' | 'contains' | 'in'
value: string
}
export default function EditAwardPage({ export default function EditAwardPage({
params, params,
@@ -46,12 +38,8 @@ export default function EditAwardPage({
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId }) const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
// Fetch competition rounds for source round selector // Rounds come from the award's included competition relation
const competitionId = award?.competitionId const competitionRounds = award?.competition?.rounds ?? []
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId! },
{ enabled: !!competitionId }
)
const updateAward = trpc.specialAward.update.useMutation({ const updateAward = trpc.specialAward.update.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -70,7 +58,6 @@ export default function EditAwardPage({
const [votingEndAt, setVotingEndAt] = useState('') const [votingEndAt, setVotingEndAt] = useState('')
const [evaluationRoundId, setEvaluationRoundId] = useState('') const [evaluationRoundId, setEvaluationRoundId] = useState('')
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN') const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
const [autoTagRules, setAutoTagRules] = useState<AutoTagRule[]>([])
// Helper to format date for datetime-local input // Helper to format date for datetime-local input
const formatDateForInput = (date: Date | string | null | undefined): string => { const formatDateForInput = (date: Date | string | null | undefined): string => {
@@ -93,14 +80,6 @@ export default function EditAwardPage({
setVotingEndAt(formatDateForInput(award.votingEndAt)) setVotingEndAt(formatDateForInput(award.votingEndAt))
setEvaluationRoundId(award.evaluationRoundId || '') setEvaluationRoundId(award.evaluationRoundId || '')
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL') setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
// Parse autoTagRulesJson
if (award.autoTagRulesJson && typeof award.autoTagRulesJson === 'object') {
const rules = award.autoTagRulesJson as { rules?: AutoTagRule[] }
setAutoTagRules(rules.rules || [])
} else {
setAutoTagRules([])
}
} }
}, [award]) }, [award])
@@ -119,7 +98,6 @@ export default function EditAwardPage({
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined, votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
evaluationRoundId: evaluationRoundId || undefined, evaluationRoundId: evaluationRoundId || undefined,
eligibilityMode, eligibilityMode,
autoTagRulesJson: autoTagRules.length > 0 ? { rules: autoTagRules } : undefined,
}) })
toast.success('Award updated') toast.success('Award updated')
router.push(`/admin/awards/${awardId}`) router.push(`/admin/awards/${awardId}`)
@@ -130,28 +108,6 @@ export default function EditAwardPage({
} }
} }
const addRule = () => {
setAutoTagRules([
...autoTagRules,
{
id: `rule-${Date.now()}`,
field: 'competitionCategory',
operator: 'equals',
value: '',
},
])
}
const removeRule = (id: string) => {
setAutoTagRules(autoTagRules.filter((r) => r.id !== id))
}
const updateRule = (id: string, updates: Partial<AutoTagRule>) => {
setAutoTagRules(
autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r))
)
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -306,9 +262,7 @@ export default function EditAwardPage({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No source round</SelectItem> <SelectItem value="none">No source round</SelectItem>
{competition?.rounds {competitionRounds.map((round) => (
?.sort((a, b) => a.sortOrder - b.sortOrder)
.map((round) => (
<SelectItem key={round.id} value={round.id}> <SelectItem key={round.id} value={round.id}>
{round.name} ({round.roundType}) {round.name} ({round.roundType})
</SelectItem> </SelectItem>
@@ -348,135 +302,6 @@ export default function EditAwardPage({
</CardContent> </CardContent>
</Card> </Card>
{/* Auto-Tag Rules */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Auto-Tag Rules</CardTitle>
<CardDescription>
Deterministic eligibility rules based on project metadata
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={addRule}>
<Plus className="mr-2 h-4 w-4" />
Add Rule
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{autoTagRules.length === 0 ? (
<div className="flex items-start gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<p>
No rules defined. Add rules to automatically filter projects based on category, location, tags, or ocean issues.
Rules work together with the source round setting.
</p>
</div>
) : (
<div className="space-y-3">
{autoTagRules.map((rule, index) => (
<div
key={rule.id}
className="flex items-start gap-3 rounded-lg border p-3"
>
<div className="flex-1 grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Field</Label>
<Select
value={rule.field}
onValueChange={(v) =>
updateRule(rule.id, {
field: v as AutoTagRule['field'],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="competitionCategory">
Competition Category
</SelectItem>
<SelectItem value="country">Country</SelectItem>
<SelectItem value="geographicZone">
Geographic Zone
</SelectItem>
<SelectItem value="tags">Tags</SelectItem>
<SelectItem value="oceanIssue">Ocean Issue</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Operator</Label>
<Select
value={rule.operator}
onValueChange={(v) =>
updateRule(rule.id, {
operator: v as AutoTagRule['operator'],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="in">In (comma-separated)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Value</Label>
<Input
className="h-9"
value={rule.value}
onChange={(e) =>
updateRule(rule.id, { value: e.target.value })
}
placeholder={
rule.operator === 'in'
? 'value1,value2,value3'
: 'Enter value...'
}
/>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => removeRule(rule.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{autoTagRules.length > 0 && (
<div className="flex items-start gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground">
<Info className="h-3 w-3 mt-0.5 shrink-0" />
<p>
<strong>How it works:</strong> Filter from{' '}
<Badge variant="outline" className="mx-1">
{evaluationRoundId
? competition?.rounds?.find((r) => r.id === evaluationRoundId)
?.name || 'Selected Round'
: 'All Projects'}
</Badge>
, where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible.
</p>
</div>
)}
</CardContent>
</Card>
{/* Voting Window Card */} {/* Voting Window Card */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -89,6 +89,8 @@ import {
Vote, Vote,
ChevronDown, ChevronDown,
AlertCircle, AlertCircle,
Layers,
Info,
} from 'lucide-react' } from 'lucide-react'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -151,6 +153,8 @@ export default function AwardDetailPage({
const [projectSearchQuery, setProjectSearchQuery] = useState('') const [projectSearchQuery, setProjectSearchQuery] = useState('')
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()) const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState('eligibility') const [activeTab, setActiveTab] = useState('eligibility')
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
// Pagination for eligibility list // Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1) const [eligibilityPage, setEligibilityPage] = useState(1)
@@ -158,7 +162,7 @@ export default function AwardDetailPage({
// Core queries — lazy-load tab-specific data based on activeTab // Core queries — lazy-load tab-specific data based on activeTab
const { data: award, isLoading, refetch } = const { data: award, isLoading, refetch } =
trpc.specialAward.get.useQuery({ id: awardId }) trpc.specialAward.get.useQuery({ id: awardId }, { refetchInterval: 30_000 })
const { data: eligibilityData, refetch: refetchEligibility } = const { data: eligibilityData, refetch: refetchEligibility } =
trpc.specialAward.listEligible.useQuery({ trpc.specialAward.listEligible.useQuery({
awardId, awardId,
@@ -175,6 +179,10 @@ export default function AwardDetailPage({
trpc.specialAward.getVoteResults.useQuery({ awardId }, { trpc.specialAward.getVoteResults.useQuery({ awardId }, {
enabled: activeTab === 'results', enabled: activeTab === 'results',
}) })
const { data: awardRounds, refetch: refetchRounds } =
trpc.specialAward.listRounds.useQuery({ awardId }, {
enabled: activeTab === 'rounds',
})
// Deferred queries - only load when needed // Deferred queries - only load when needed
const { data: allUsers } = trpc.user.list.useQuery( const { data: allUsers } = trpc.user.list.useQuery(
@@ -258,6 +266,22 @@ export default function AwardDetailPage({
const deleteAward = trpc.specialAward.delete.useMutation({ const deleteAward = trpc.specialAward.delete.useMutation({
onSuccess: () => utils.specialAward.list.invalidate(), onSuccess: () => utils.specialAward.list.invalidate(),
}) })
const createRound = trpc.specialAward.createRound.useMutation({
onSuccess: () => {
refetchRounds()
setAddRoundOpen(false)
setRoundForm({ name: '', roundType: 'EVALUATION' })
toast.success('Round created')
},
onError: (err) => toast.error(err.message),
})
const deleteRound = trpc.specialAward.deleteRound.useMutation({
onSuccess: () => {
refetchRounds()
toast.success('Round deleted')
},
onError: (err) => toast.error(err.message),
})
const handleStatusChange = async ( const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED' status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
@@ -414,7 +438,7 @@ export default function AwardDetailPage({
</h1> </h1>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}> <Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
{award.status.replace('_', ' ')} {award.status.replace(/_/g, ' ')}
</Badge> </Badge>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{award.program.year} Edition {award.program.year} Edition
@@ -570,7 +594,7 @@ export default function AwardDetailPage({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
<p className="text-2xl font-bold tabular-nums">{award._count.eligibilities}</p> <p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" /> <ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
@@ -619,6 +643,10 @@ export default function AwardDetailPage({
<Users className="mr-2 h-4 w-4" /> <Users className="mr-2 h-4 w-4" />
Jurors ({award._count.jurors}) Jurors ({award._count.jurors})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="rounds">
<Layers className="mr-2 h-4 w-4" />
Rounds {awardRounds ? `(${awardRounds.length})` : ''}
</TabsTrigger>
<TabsTrigger value="results"> <TabsTrigger value="results">
<BarChart3 className="mr-2 h-4 w-4" /> <BarChart3 className="mr-2 h-4 w-4" />
Results Results
@@ -629,7 +657,7 @@ export default function AwardDetailPage({
<TabsContent value="eligibility" className="space-y-4"> <TabsContent value="eligibility" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{award.eligibleCount} of {award._count.eligibilities} projects {award.eligibleCount} of {(award as any).totalAssessed ?? award._count.eligibilities} projects
eligible eligible
</p> </p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -1083,6 +1111,199 @@ export default function AwardDetailPage({
)} )}
</TabsContent> </TabsContent>
{/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4">
{award.eligibilityMode !== 'SEPARATE_POOL' && (
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
</p>
</div>
)}
{!award.competitionId && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Link this award to a competition first before creating rounds.
</p>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Award Rounds ({awardRounds?.length ?? 0})</h2>
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" disabled={!award.competitionId}>
<Plus className="h-4 w-4 mr-1" />
Add Round
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Award Round</DialogTitle>
<DialogDescription>
Add a new round to the &quot;{award.name}&quot; award evaluation track.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="round-name">Round Name</Label>
<Input
id="round-name"
placeholder="e.g. Award Evaluation"
value={roundForm.name}
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="round-type">Round Type</Label>
<Select
value={roundForm.roundType}
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
>
<SelectTrigger id="round-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVALUATION">Evaluation</SelectItem>
<SelectItem value="FILTERING">Filtering</SelectItem>
<SelectItem value="SUBMISSION">Submission</SelectItem>
<SelectItem value="MENTORING">Mentoring</SelectItem>
<SelectItem value="LIVE_FINAL">Live Final</SelectItem>
<SelectItem value="DELIBERATION">Deliberation</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
<Button
onClick={() => createRound.mutate({
awardId,
name: roundForm.name.trim(),
roundType: roundForm.roundType as any,
})}
disabled={!roundForm.name.trim() || createRound.isPending}
>
{createRound.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating...</>
) : 'Create Round'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{!awardRounds ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
) : awardRounds.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds yet. Create your first award round to build an evaluation track.
</CardContent>
</Card>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{awardRounds.map((round: any, index: number) => {
const projectCount = round._count?.projectRoundStates ?? 0
const assignmentCount = round._count?.assignments ?? 0
const statusLabel = round.status.replace('ROUND_', '')
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-600',
ACTIVE: 'bg-emerald-100 text-emerald-700',
CLOSED: 'bg-blue-100 text-blue-700',
ARCHIVED: 'bg-muted text-muted-foreground',
}
const roundTypeColors: Record<string, string> = {
EVALUATION: 'bg-violet-100 text-violet-700',
FILTERING: 'bg-amber-100 text-amber-700',
SUBMISSION: 'bg-blue-100 text-blue-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-rose-100 text-rose-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
return (
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
<CardContent className="pt-4 pb-3 space-y-3">
<div className="flex items-start gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
{round.name}
</Link>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
{statusLabel}
</Badge>
{index === 0 && (
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
Entry point
</Badge>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Layers className="h-3.5 w-3.5" />
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
</div>
{assignmentCount > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<ListChecks className="h-3.5 w-3.5" />
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
{round.status === 'ROUND_DRAFT' && (
<div className="flex justify-end pt-1">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{round.name}&quot;. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRound.mutate({ roundId: round.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</CardContent>
</Card>
)
})}
</div>
)}
</TabsContent>
{/* Results Tab */} {/* Results Tab */}
<TabsContent value="results" className="space-y-4"> <TabsContent value="results" className="space-y-4">
{voteResults && voteResults.results.length > 0 ? (() => { {voteResults && voteResults.results.length > 0 ? (() => {

View File

@@ -40,7 +40,10 @@ const SCORING_LABELS: Record<string, string> = {
} }
export default function AwardsListPage() { export default function AwardsListPage() {
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({}) const { data: awards, isLoading } = trpc.specialAward.list.useQuery(
{},
{ refetchInterval: 30_000 }
)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300) const debouncedSearch = useDebounce(search, 300)
@@ -168,7 +171,7 @@ export default function AwardsListPage() {
{award.name} {award.name}
</CardTitle> </CardTitle>
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}> <Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
{award.status.replace('_', ' ')} {award.status.replace(/_/g, ' ')}
</Badge> </Badge>
</div> </div>
{award.description && ( {award.description && (

View File

@@ -2,7 +2,8 @@
import { useState } from 'react' import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { ArrowLeft, PlayCircle } from 'lucide-react' import { ArrowLeft, Loader2, PlayCircle, Zap } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -26,6 +27,16 @@ export default function AssignmentsDashboardPage() {
const [selectedRoundId, setSelectedRoundId] = useState<string>('') const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const [previewSheetOpen, setPreviewSheetOpen] = useState(false) const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({
onSuccess: () => {
toast.success('AI assignments ready!', {
action: { label: 'Review', onClick: () => setPreviewSheetOpen(true) },
duration: 10000,
})
},
onError: (err) => toast.error(`AI generation failed: ${err.message}`),
})
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({ const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
id: competitionId, id: competitionId,
}) })
@@ -58,7 +69,18 @@ export default function AssignmentsDashboardPage() {
if (!competition) { if (!competition) {
return ( return (
<div className="container mx-auto space-y-6 p-4 sm:p-6"> <div className="container mx-auto space-y-6 p-4 sm:p-6">
<p>Competition not found</p> <Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="font-medium">Competition not found</p>
<p className="text-sm text-muted-foreground mt-1">
The requested competition does not exist or you don&apos;t have access.
</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
</CardContent>
</Card>
</div> </div>
) )
} }
@@ -104,11 +126,24 @@ export default function AssignmentsDashboardPage() {
{selectedRoundId && ( {selectedRoundId && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-end"> <div className="flex justify-end gap-2">
<Button onClick={() => setPreviewSheetOpen(true)}> <Button
<PlayCircle className="mr-2 h-4 w-4" /> onClick={() => {
Generate Assignments aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })
}}
disabled={aiAssignmentMutation.isPending}
>
{aiAssignmentMutation.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Generating...</>
) : (
<><Zap className="mr-2 h-4 w-4" />{aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}</>
)}
</Button> </Button>
{aiAssignmentMutation.data && (
<Button variant="outline" onClick={() => setPreviewSheetOpen(true)}>
Review Assignments
</Button>
)}
</div> </div>
<Tabs defaultValue="coverage" className="w-full"> <Tabs defaultValue="coverage" className="w-full">
@@ -170,6 +205,10 @@ export default function AssignmentsDashboardPage() {
open={previewSheetOpen} open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen} onOpenChange={setPreviewSheetOpen}
requiredReviews={requiredReviews} requiredReviews={requiredReviews}
aiResult={aiAssignmentMutation.data ?? null}
isAIGenerating={aiAssignmentMutation.isPending}
onGenerateAI={() => aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })}
onResetAI={() => aiAssignmentMutation.reset()}
/> />
</div> </div>
)} )}

View File

@@ -22,8 +22,10 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
description: '', description: '',
criteriaText: '',
useAiEligibility: false, useAiEligibility: false,
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED' scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED',
maxRankedPicks: '3',
}); });
const { data: competition } = trpc.competition.getById.useQuery({ const { data: competition } = trpc.competition.getById.useQuery({
@@ -60,10 +62,13 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
createMutation.mutate({ createMutation.mutate({
programId: competition.programId, programId: competition.programId,
competitionId: params.competitionId,
name: formData.name.trim(), name: formData.name.trim(),
description: formData.description.trim() || undefined, description: formData.description.trim() || undefined,
criteriaText: formData.criteriaText.trim() || undefined,
scoringMode: formData.scoringMode, scoringMode: formData.scoringMode,
useAiEligibility: formData.useAiEligibility useAiEligibility: formData.useAiEligibility,
maxRankedPicks: formData.scoringMode === 'RANKED' ? parseInt(formData.maxRankedPicks) : undefined,
}); });
}; };
@@ -113,22 +118,17 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label> <Label htmlFor="criteriaText">Eligibility Criteria</Label>
<Select <Textarea
value={formData.scoringMode} id="criteriaText"
onValueChange={(value) => value={formData.criteriaText}
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' }) onChange={(e) => setFormData({ ...formData, criteriaText: e.target.value })}
} placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
> rows={4}
<SelectTrigger id="scoringMode"> />
<SelectValue /> <p className="text-xs text-muted-foreground">
</SelectTrigger> This text will be used by AI to determine which projects are eligible for this award.
<SelectContent> </p>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -144,6 +144,41 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
</Label> </Label>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<Select
value={formData.scoringMode}
onValueChange={(value) =>
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
}
>
<SelectTrigger id="scoringMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner Each juror picks 1</SelectItem>
<SelectItem value="RANKED">Ranked Each juror ranks top N</SelectItem>
<SelectItem value="SCORED">Scored Use evaluation form</SelectItem>
</SelectContent>
</Select>
</div>
{formData.scoringMode === 'RANKED' && (
<div className="space-y-2">
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
<Input
id="maxPicks"
type="number"
min="1"
max="20"
value={formData.maxRankedPicks}
onChange={(e) => setFormData({ ...formData, maxRankedPicks: e.target.value })}
/>
</div>
)}
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button <Button
type="button" type="button"

View File

@@ -13,16 +13,34 @@ import type { Route } from 'next';
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) { export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
const params = use(paramsPromise); const params = use(paramsPromise);
const router = useRouter(); const router = useRouter();
const { data: competition } = trpc.competition.getById.useQuery({ const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
id: params.competitionId id: params.competitionId
}); });
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({ const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
programId: competition?.programId programId: competition?.programId
}, { }, {
enabled: !!competition?.programId enabled: !!competition?.programId
}); });
if (isCompError || isAwardsError) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Awards</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or awards data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { use } from 'react'; import { use, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { trpc } from '@/lib/trpc/client'; import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -12,6 +12,30 @@ import { toast } from 'sonner';
import { ResultsPanel } from '@/components/admin/deliberation/results-panel'; import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
import type { Route } from 'next'; import type { Route } from 'next';
const STATUS_LABELS: Record<string, string> = {
DELIB_OPEN: 'Open',
VOTING: 'Voting',
TALLYING: 'Tallying',
RUNOFF: 'Runoff',
DELIB_LOCKED: 'Locked',
};
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DELIB_OPEN: 'outline',
VOTING: 'default',
TALLYING: 'secondary',
RUNOFF: 'secondary',
DELIB_LOCKED: 'secondary',
};
const CATEGORY_LABELS: Record<string, string> = {
STARTUP: 'Startup',
BUSINESS_CONCEPT: 'Business Concept',
};
const TIE_BREAK_LABELS: Record<string, string> = {
TIE_RUNOFF: 'Runoff Vote',
TIE_ADMIN_DECIDES: 'Admin Decides',
SCORE_FALLBACK: 'Score Fallback',
};
export default function DeliberationSessionPage({ export default function DeliberationSessionPage({
params: paramsPromise params: paramsPromise
}: { }: {
@@ -21,9 +45,10 @@ export default function DeliberationSessionPage({
const router = useRouter(); const router = useRouter();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({ const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
sessionId: params.sessionId { sessionId: params.sessionId },
}); { refetchInterval: 10_000 }
);
const openVotingMutation = trpc.deliberation.openVoting.useMutation({ const openVotingMutation = trpc.deliberation.openVoting.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -45,6 +70,12 @@ export default function DeliberationSessionPage({
} }
}); });
// Derive which participants have voted from the votes array
const voterUserIds = useMemo(() => {
if (!session?.votes) return new Set<string>();
return new Set(session.votes.map((v: any) => v.juryMember?.user?.id).filter(Boolean));
}, [session?.votes]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -90,10 +121,10 @@ export default function DeliberationSessionPage({
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">Deliberation Session</h1> <h1 className="text-3xl font-bold">Deliberation Session</h1>
<Badge>{session.status}</Badge> <Badge variant={STATUS_VARIANTS[session.status] ?? 'outline'}>{STATUS_LABELS[session.status] ?? session.status}</Badge>
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{session.round?.name} - {session.category} {session.round?.name} - {CATEGORY_LABELS[session.category] ?? session.category}
</p> </p>
</div> </div>
</div> </div>
@@ -121,7 +152,7 @@ export default function DeliberationSessionPage({
</div> </div>
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p> <p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
<p className="mt-1">{session.tieBreakMethod}</p> <p className="mt-1">{TIE_BREAK_LABELS[session.tieBreakMethod] ?? session.tieBreakMethod}</p>
</div> </div>
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">
@@ -149,11 +180,11 @@ export default function DeliberationSessionPage({
className="flex items-center justify-between rounded-lg border p-3" className="flex items-center justify-between rounded-lg border p-3"
> >
<div> <div>
<p className="font-medium">{participant.user?.name}</p> <p className="font-medium">{participant.user?.user?.name ?? 'Unknown'}</p>
<p className="text-sm text-muted-foreground">{participant.user?.email}</p> <p className="text-sm text-muted-foreground">{participant.user?.user?.email}</p>
</div> </div>
<Badge variant={participant.hasVoted ? 'default' : 'outline'}> <Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'outline'}>
{participant.hasVoted ? 'Voted' : 'Pending'} {voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'}
</Badge> </Badge>
</div> </div>
))} ))}
@@ -183,7 +214,7 @@ export default function DeliberationSessionPage({
variant="destructive" variant="destructive"
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })} onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
disabled={ disabled={
closeVotingMutation.isPending || session.status !== 'DELIB_VOTING' closeVotingMutation.isPending || session.status !== 'VOTING'
} }
className="flex-1" className="flex-1"
> >
@@ -204,9 +235,9 @@ export default function DeliberationSessionPage({
key={participant.id} key={participant.id}
className="flex items-center justify-between rounded-lg border p-3" className="flex items-center justify-between rounded-lg border p-3"
> >
<span>{participant.user?.name}</span> <span>{participant.user?.user?.name ?? 'Unknown'}</span>
<Badge variant={participant.hasVoted ? 'default' : 'secondary'}> <Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'secondary'}>
{participant.hasVoted ? 'Submitted' : 'Not Voted'} {voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'}
</Badge> </Badge>
</div> </div>
))} ))}

View File

@@ -32,6 +32,7 @@ export default function DeliberationListPage({
const router = useRouter(); const router = useRouter();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedJuryGroupId, setSelectedJuryGroupId] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
roundId: '', roundId: '',
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT', category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
@@ -42,20 +43,29 @@ export default function DeliberationListPage({
participantUserIds: [] as string[] participantUserIds: [] as string[]
}); });
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery( const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
{ competitionId: params.competitionId }, { competitionId: params.competitionId },
{ enabled: !!params.competitionId } { enabled: !!params.competitionId }
); );
// Get rounds for this competition // Get rounds for this competition
const { data: competition } = trpc.competition.getById.useQuery( const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
{ id: params.competitionId }, { id: params.competitionId },
{ enabled: !!params.competitionId } { enabled: !!params.competitionId }
); );
const rounds = competition?.rounds || []; const rounds = competition?.rounds || [];
// TODO: Add getJuryMembers endpoint if needed for participant selection // Jury groups & members for participant selection
const juryMembers: any[] = []; const { data: juryGroups = [] } = trpc.juryGroup.list.useQuery(
{ competitionId: params.competitionId },
{ enabled: !!params.competitionId }
);
const { data: selectedJuryGroup } = trpc.juryGroup.getById.useQuery(
{ id: selectedJuryGroupId },
{ enabled: !!selectedJuryGroupId }
);
const juryMembers = selectedJuryGroup?.members ?? [];
const createSessionMutation = trpc.deliberation.createSession.useMutation({ const createSessionMutation = trpc.deliberation.createSession.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
@@ -76,6 +86,10 @@ export default function DeliberationListPage({
toast.error('Please select a round'); toast.error('Please select a round');
return; return;
} }
if (formData.participantUserIds.length === 0) {
toast.error('Please select at least one participant');
return;
}
createSessionMutation.mutate({ createSessionMutation.mutate({
competitionId: params.competitionId, competitionId: params.competitionId,
@@ -92,12 +106,38 @@ export default function DeliberationListPage({
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DELIB_OPEN: 'outline', DELIB_OPEN: 'outline',
DELIB_VOTING: 'default', VOTING: 'default',
DELIB_TALLYING: 'secondary', TALLYING: 'secondary',
DELIB_LOCKED: 'destructive' RUNOFF: 'secondary',
DELIB_LOCKED: 'secondary',
}; };
return <Badge variant={variants[status] || 'outline'}>{status}</Badge>; const labels: Record<string, string> = {
DELIB_OPEN: 'Open',
VOTING: 'Voting',
TALLYING: 'Tallying',
RUNOFF: 'Runoff',
DELIB_LOCKED: 'Locked',
}; };
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
};
if (isCompError || isSessionsError) {
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Go back">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Deliberations</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or deliberation data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) { if (isLoading) {
return ( return (
@@ -151,7 +191,7 @@ export default function DeliberationListPage({
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<CardTitle> <CardTitle>
{session.round?.name} - {session.category} {session.round?.name} - {session.category === 'BUSINESS_CONCEPT' ? 'Business Concept' : session.category === 'STARTUP' ? 'Startup' : session.category}
</CardTitle> </CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'} {session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
@@ -164,7 +204,7 @@ export default function DeliberationListPage({
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{session.participants?.length || 0} participants</span> <span>{session.participants?.length || 0} participants</span>
<span></span> <span></span>
<span>Tie break: {session.tieBreakMethod}</span> <span>Tie break: {session.tieBreakMethod === 'TIE_RUNOFF' ? 'Runoff Vote' : session.tieBreakMethod === 'TIE_ADMIN_DECIDES' ? 'Admin Decides' : session.tieBreakMethod === 'SCORE_FALLBACK' ? 'Score Fallback' : session.tieBreakMethod}</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -273,6 +313,78 @@ export default function DeliberationListPage({
</Select> </Select>
</div> </div>
{/* Participant Selection */}
<div className="space-y-2">
<Label htmlFor="juryGroup">Jury Group *</Label>
<Select
value={selectedJuryGroupId}
onValueChange={(value) => {
setSelectedJuryGroupId(value);
setFormData({ ...formData, participantUserIds: [] });
}}
>
<SelectTrigger id="juryGroup">
<SelectValue placeholder="Select jury group" />
</SelectTrigger>
<SelectContent>
{juryGroups.map((group: any) => (
<SelectItem key={group.id} value={group.id}>
{group.name} ({group._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{juryMembers.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Participants ({formData.participantUserIds.length}/{juryMembers.length})</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const allIds = juryMembers.map((m: any) => m.user.id);
const allSelected = allIds.every((id: string) => formData.participantUserIds.includes(id));
setFormData({
...formData,
participantUserIds: allSelected ? [] : allIds,
});
}}
>
{juryMembers.every((m: any) => formData.participantUserIds.includes(m.user.id))
? 'Deselect All'
: 'Select All'}
</Button>
</div>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3">
{juryMembers.map((member: any) => (
<div key={member.id} className="flex items-center space-x-2">
<Checkbox
id={`member-${member.user.id}`}
checked={formData.participantUserIds.includes(member.user.id)}
onCheckedChange={(checked) => {
setFormData({
...formData,
participantUserIds: checked
? [...formData.participantUserIds, member.user.id]
: formData.participantUserIds.filter((id: string) => id !== member.user.id),
});
}}
/>
<Label htmlFor={`member-${member.user.id}`} className="flex-1 font-normal">
{member.user.name || member.user.email}
<span className="ml-2 text-xs text-muted-foreground">
{member.role === 'CHAIR' ? 'Chair' : member.role === 'OBSERVER' ? 'Observer' : 'Member'}
</span>
</Label>
</div>
))}
</div>
</div>
)}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox

View File

@@ -15,7 +15,10 @@ export default function JuryGroupDetailPage() {
const router = useRouter() const router = useRouter()
const juryGroupId = params.juryGroupId as string const juryGroupId = params.juryGroupId as string
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery({ id: juryGroupId }) const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery(
{ id: juryGroupId },
{ refetchInterval: 30_000 }
)
if (isLoading) { if (isLoading) {
return ( return (

View File

@@ -48,6 +48,7 @@ import {
Loader2, Loader2,
Plus, Plus,
CalendarDays, CalendarDays,
Radio,
} from 'lucide-react' } from 'lucide-react'
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline' import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
@@ -104,9 +105,10 @@ export default function CompetitionDetailPage() {
roundType: '' as string, roundType: '' as string,
}) })
const { data: competition, isLoading } = trpc.competition.getById.useQuery({ const { data: competition, isLoading } = trpc.competition.getById.useQuery(
id: competitionId, { id: competitionId },
}) { refetchInterval: 30_000 }
)
const updateMutation = trpc.competition.update.useMutation({ const updateMutation = trpc.competition.update.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -284,7 +286,7 @@ export default function CompetitionDetailPage() {
<Layers className="h-4 w-4 text-blue-500" /> <Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Rounds</span> <span className="text-sm font-medium">Rounds</span>
</div> </div>
<p className="text-2xl font-bold mt-1">{competition.rounds.length}</p> <p className="text-2xl font-bold mt-1">{competition.rounds.filter((r: any) => !r.specialAwardId).length}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -303,7 +305,7 @@ export default function CompetitionDetailPage() {
<span className="text-sm font-medium">Projects</span> <span className="text-sm font-medium">Projects</span>
</div> </div>
<p className="text-2xl font-bold mt-1"> <p className="text-2xl font-bold mt-1">
{competition.rounds.reduce((sum: number, r: any) => sum + (r._count?.projectRoundStates ?? 0), 0)} {(competition as any).distinctProjectCount ?? 0}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -331,21 +333,21 @@ export default function CompetitionDetailPage() {
<TabsContent value="overview" className="space-y-6"> <TabsContent value="overview" className="space-y-6">
<CompetitionTimeline <CompetitionTimeline
competitionId={competitionId} competitionId={competitionId}
rounds={competition.rounds} rounds={competition.rounds.filter((r: any) => !r.specialAwardId)}
/> />
</TabsContent> </TabsContent>
{/* Rounds Tab */} {/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4"> <TabsContent value="rounds" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.length})</h2> <h2 className="text-lg font-semibold">Rounds ({competition.rounds.filter((r: any) => !r.specialAwardId).length})</h2>
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}> <Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
Add Round Add Round
</Button> </Button>
</div> </div>
{competition.rounds.length === 0 ? ( {competition.rounds.filter((r: any) => !r.specialAwardId).length === 0 ? (
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground"> <CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds configured. Add rounds to define the competition flow. No rounds configured. Add rounds to define the competition flow.
@@ -353,7 +355,7 @@ export default function CompetitionDetailPage() {
</Card> </Card>
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{competition.rounds.map((round: any, index: number) => { {competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
const projectCount = round._count?.projectRoundStates ?? 0 const projectCount = round._count?.projectRoundStates ?? 0
const assignmentCount = round._count?.assignments ?? 0 const assignmentCount = round._count?.assignments ?? 0
const statusLabel = round.status.replace('ROUND_', '') const statusLabel = round.status.replace('ROUND_', '')
@@ -385,7 +387,7 @@ export default function CompetitionDetailPage() {
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700' roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
)} )}
> >
{round.roundType.replace('_', ' ')} {round.roundType.replace(/_/g, ' ')}
</Badge> </Badge>
<Badge <Badge
variant="outline" variant="outline"
@@ -434,6 +436,19 @@ export default function CompetitionDetailPage() {
<span className="truncate">{round.juryGroup.name}</span> <span className="truncate">{round.juryGroup.name}</span>
</div> </div>
)} )}
{/* Live Control link for LIVE_FINAL rounds */}
{round.roundType === 'LIVE_FINAL' && (
<Link
href={`/admin/competitions/${competitionId}/live/${round.id}` as Route}
onClick={(e) => e.stopPropagation()}
>
<Button size="sm" variant="outline" className="w-full text-xs gap-1.5">
<Radio className="h-3.5 w-3.5" />
Live Control
</Button>
</Link>
)}
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>

View File

@@ -53,7 +53,7 @@ export default function CompetitionListPage() {
const { data: competitions, isLoading } = trpc.competition.list.useQuery( const { data: competitions, isLoading } = trpc.competition.list.useQuery(
{ programId: programId! }, { programId: programId! },
{ enabled: !!programId } { enabled: !!programId, refetchInterval: 30_000 }
) )
if (!programId) { if (!programId) {

View File

@@ -22,6 +22,7 @@ import { ProjectListCompact } from '@/components/dashboard/project-list-compact'
import { ActivityFeed } from '@/components/dashboard/activity-feed' import { ActivityFeed } from '@/components/dashboard/activity-feed'
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown' import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton' import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
type DashboardContentProps = { type DashboardContentProps = {
editionId: string editionId: string
@@ -33,6 +34,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{ editionId }, { editionId },
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 } { enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
) )
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
{ editionId, limit: 8 },
{ enabled: !!editionId, refetchInterval: 30_000 }
)
if (isLoading) { if (isLoading) {
return <DashboardSkeleton /> return <DashboardSkeleton />
@@ -158,15 +163,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<AnimatedCard index={3}> <AnimatedCard index={3}>
<ProjectListCompact projects={latestProjects} /> <ProjectListCompact projects={latestProjects} />
</AnimatedCard> </AnimatedCard>
{recentEvals && recentEvals.length > 0 && (
<AnimatedCard index={4}>
<RecentEvaluations evaluations={recentEvals} />
</AnimatedCard>
)}
</div> </div>
{/* Right Column */} {/* Right Column */}
<div className="space-y-6 lg:col-span-4"> <div className="space-y-6 lg:col-span-4">
<AnimatedCard index={4}> <AnimatedCard index={5}>
<SmartActions actions={nextActions} /> <SmartActions actions={nextActions} />
</AnimatedCard> </AnimatedCard>
<AnimatedCard index={5}> <AnimatedCard index={6}>
<ActivityFeed activity={recentActivity} /> <ActivityFeed activity={recentActivity} />
</AnimatedCard> </AnimatedCard>
</div> </div>
@@ -175,12 +186,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Bottom Full Width */} {/* Bottom Full Width */}
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-6 lg:grid-cols-12">
<div className="lg:col-span-8"> <div className="lg:col-span-8">
<AnimatedCard index={6}> <AnimatedCard index={7}>
<GeographicSummaryCard programId={editionId} /> <GeographicSummaryCard programId={editionId} />
</AnimatedCard> </AnimatedCard>
</div> </div>
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<AnimatedCard index={7}> <AnimatedCard index={8}>
<CategoryBreakdown <CategoryBreakdown
categories={categoryBreakdown} categories={categoryBreakdown}
issues={oceanIssueBreakdown} issues={oceanIssueBreakdown}

View File

@@ -1,10 +0,0 @@
import { redirect } from 'next/navigation'
export default async function MentorDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
redirect(`/admin/members/${id}`)
}

View File

@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function MentorsPage() {
redirect('/admin/members')
}

View File

@@ -79,7 +79,7 @@ const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'
export default function MessagesPage() { export default function MessagesPage() {
const [recipientType, setRecipientType] = useState<RecipientType>('ALL') const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
const [selectedRole, setSelectedRole] = useState('') const [selectedRole, setSelectedRole] = useState('')
const [roundId, setStageId] = useState('') const [roundId, setRoundId] = useState('')
const [selectedProgramId, setSelectedProgramId] = useState('') const [selectedProgramId, setSelectedProgramId] = useState('')
const [selectedUserId, setSelectedUserId] = useState('') const [selectedUserId, setSelectedUserId] = useState('')
const [subject, setSubject] = useState('') const [subject, setSubject] = useState('')
@@ -104,9 +104,10 @@ export default function MessagesPage() {
{ enabled: recipientType === 'USER' } { enabled: recipientType === 'USER' }
) )
// Fetch sent messages for history // Fetch sent messages for history (messages sent BY this admin)
const { data: sentMessages, isLoading: loadingSent } = trpc.message.inbox.useQuery( const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery(
{ page: 1, pageSize: 50 } { page: 1, pageSize: 50 },
{ refetchInterval: 30_000 }
) )
const sendMutation = trpc.message.send.useMutation({ const sendMutation = trpc.message.send.useMutation({
@@ -114,7 +115,7 @@ export default function MessagesPage() {
const count = (data as Record<string, unknown>)?.recipientCount || '' const count = (data as Record<string, unknown>)?.recipientCount || ''
toast.success(`Message sent successfully${count ? ` to ${count} recipients` : ''}`) toast.success(`Message sent successfully${count ? ` to ${count} recipients` : ''}`)
resetForm() resetForm()
utils.message.inbox.invalidate() utils.message.sent.invalidate()
}, },
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}) })
@@ -124,7 +125,7 @@ export default function MessagesPage() {
setBody('') setBody('')
setSelectedTemplateId('') setSelectedTemplateId('')
setSelectedRole('') setSelectedRole('')
setStageId('') setRoundId('')
setSelectedProgramId('') setSelectedProgramId('')
setSelectedUserId('') setSelectedUserId('')
setIsScheduled(false) setIsScheduled(false)
@@ -218,7 +219,7 @@ export default function MessagesPage() {
return return
} }
if (recipientType === 'ROUND_JURY' && !roundId) { if (recipientType === 'ROUND_JURY' && !roundId) {
toast.error('Please select a stage') toast.error('Please select a round')
return return
} }
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) { if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
@@ -295,7 +296,7 @@ export default function MessagesPage() {
onValueChange={(v) => { onValueChange={(v) => {
setRecipientType(v as RecipientType) setRecipientType(v as RecipientType)
setSelectedRole('') setSelectedRole('')
setStageId('') setRoundId('')
setSelectedProgramId('') setSelectedProgramId('')
setSelectedUserId('') setSelectedUserId('')
}} }}
@@ -334,10 +335,10 @@ export default function MessagesPage() {
{recipientType === 'ROUND_JURY' && ( {recipientType === 'ROUND_JURY' && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Select Stage</Label> <Label>Select Round</Label>
<Select value={roundId} onValueChange={setStageId}> <Select value={roundId} onValueChange={setRoundId}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Choose a stage..." /> <SelectValue placeholder="Choose a round..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{rounds?.map((round) => ( {rounds?.map((round) => (
@@ -564,56 +565,73 @@ export default function MessagesPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Subject</TableHead> <TableHead>Subject</TableHead>
<TableHead className="hidden md:table-cell">From</TableHead> <TableHead className="hidden md:table-cell">Recipients</TableHead>
<TableHead className="hidden md:table-cell">Channel</TableHead> <TableHead className="hidden md:table-cell">Channels</TableHead>
<TableHead className="hidden lg:table-cell">Status</TableHead> <TableHead className="hidden lg:table-cell">Status</TableHead>
<TableHead className="text-right">Date</TableHead> <TableHead className="text-right">Date</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{sentMessages.items.map((item: Record<string, unknown>) => { {sentMessages.items.map((msg: any) => {
const msg = item.message as Record<string, unknown> | undefined const channels = (msg.deliveryChannels as string[]) || []
const sender = msg?.sender as Record<string, unknown> | undefined const recipientCount = msg._count?.recipients ?? 0
const channel = String(item.channel || 'EMAIL') const isSent = !!msg.sentAt
const isRead = !!item.isRead
return ( return (
<TableRow key={String(item.id)}> <TableRow key={msg.id}>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <span className="font-medium">
{!isRead && ( {msg.subject || 'No subject'}
<div className="h-2 w-2 rounded-full bg-primary shrink-0" />
)}
<span className={isRead ? 'text-muted-foreground' : 'font-medium'}>
{String(msg?.subject || 'No subject')}
</span> </span>
</div>
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground"> <TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{String(sender?.name || sender?.email || 'System')} {msg.recipientType === 'ALL'
? 'All users'
: msg.recipientType === 'ROLE'
? `By role`
: msg.recipientType === 'ROUND_JURY'
? 'Round jury'
: msg.recipientType === 'USER'
? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}`
: msg.recipientType}
{recipientCount > 0 && ` (${recipientCount})`}
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell"> <TableCell className="hidden md:table-cell">
<div className="flex gap-1">
{channels.includes('EMAIL') && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{channel === 'EMAIL' ? ( <Mail className="mr-1 h-3 w-3" />Email
<><Mail className="mr-1 h-3 w-3" />Email</>
) : (
<><Bell className="mr-1 h-3 w-3" />In-App</>
)}
</Badge> </Badge>
)}
{channels.includes('IN_APP') && (
<Badge variant="outline" className="text-xs">
<Bell className="mr-1 h-3 w-3" />In-App
</Badge>
)}
</div>
</TableCell> </TableCell>
<TableCell className="hidden lg:table-cell"> <TableCell className="hidden lg:table-cell">
{isRead ? ( {isSent ? (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" /> <CheckCircle2 className="mr-1 h-3 w-3" />
Read Sent
</Badge>
) : msg.scheduledAt ? (
<Badge variant="default" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
Scheduled
</Badge> </Badge>
) : ( ) : (
<Badge variant="default" className="text-xs">New</Badge> <Badge variant="outline" className="text-xs">
Draft
</Badge>
)} )}
</TableCell> </TableCell>
<TableCell className="text-right text-sm text-muted-foreground"> <TableCell className="text-right text-sm text-muted-foreground">
{msg?.createdAt {msg.sentAt
? formatDate(msg.createdAt as string | Date) ? formatDate(msg.sentAt)
: msg.scheduledAt
? formatDate(msg.scheduledAt)
: ''} : ''}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) { if (!editionId) {
const defaultEdition = await prisma.program.findFirst({ const defaultEdition = await prisma.program.findFirst({
where: { status: 'ACTIVE' }, where: { status: 'ACTIVE', isTest: false },
orderBy: { year: 'desc' }, orderBy: { year: 'desc' },
select: { id: true }, select: { id: true },
}) })
@@ -38,6 +38,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) { if (!editionId) {
const anyEdition = await prisma.program.findFirst({ const anyEdition = await prisma.program.findFirst({
where: { isTest: false },
orderBy: { year: 'desc' }, orderBy: { year: 'desc' },
select: { id: true }, select: { id: true },
}) })

View File

@@ -19,7 +19,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { ArrowLeft, Pencil, Plus } from 'lucide-react' import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
interface ProgramDetailPageProps { interface ProgramDetailPageProps {
@@ -65,6 +65,13 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/mentorship` as Route}>
<GraduationCap className="mr-2 h-4 w-4" />
Mentorship
</Link>
</Button>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}> <Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
@@ -72,6 +79,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</Link> </Link>
</Button> </Button>
</div> </div>
</div>
{program.description && ( {program.description && (
<Card> <Card>
@@ -108,7 +116,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Stage</TableHead> <TableHead>Round</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Projects</TableHead> <TableHead>Projects</TableHead>
<TableHead>Assignments</TableHead> <TableHead>Assignments</TableHead>

View File

@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
async function ProgramsContent() { async function ProgramsContent() {
const programs = await prisma.program.findMany({ const programs = await prisma.program.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation where: { isTest: false },
include: { include: {
competitions: { competitions: {
include: { include: {

View File

@@ -21,7 +21,6 @@ import {
Bot, Bot,
Loader2, Loader2,
Users, Users,
User,
Check, Check,
RefreshCw, RefreshCw,
} from 'lucide-react' } from 'lucide-react'
@@ -338,24 +337,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Manual Assignment */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5" />
Manual Assignment
</CardTitle>
<CardDescription>
Search and select a mentor manually
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Use the AI suggestions above or search for a specific user in the Users section
to assign them as a mentor manually.
</p>
</CardContent>
</Card>
</> </>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { Suspense, use } from 'react' import { Suspense, use, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@@ -28,6 +28,13 @@ import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card' import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { import {
ArrowLeft, ArrowLeft,
@@ -36,9 +43,6 @@ import {
Users, Users,
FileText, FileText,
Calendar, Calendar,
CheckCircle2,
XCircle,
Circle,
Clock, Clock,
BarChart3, BarChart3,
ThumbsUp, ThumbsUp,
@@ -51,6 +55,8 @@ import {
UserPlus, UserPlus,
Loader2, Loader2,
ScanSearch, ScanSearch,
Eye,
MessageSquare,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils' import { formatDate, formatDateOnly } from '@/lib/utils'
@@ -79,9 +85,10 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
function ProjectDetailContent({ projectId }: { projectId: string }) { function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch project + assignments + stats in a single combined query // Fetch project + assignments + stats in a single combined query
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery({ const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
id: projectId, { id: projectId },
}) { refetchInterval: 30_000 }
)
const project = fullDetail?.project const project = fullDetail?.project
const assignments = fullDetail?.assignments const assignments = fullDetail?.assignments
@@ -117,6 +124,10 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
// State for evaluation detail sheet
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
if (isLoading) { if (isLoading) {
return <ProjectDetailSkeleton /> return <ProjectDetailSkeleton />
} }
@@ -548,105 +559,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Requirements organized by round */} {/* File upload */}
{competitionRounds.length > 0 && allRequirements.length > 0 ? (
<>
{competitionRounds.map((round: { id: string; name: string }) => {
const roundRequirements = allRequirements.filter((req: any) => req.roundId === round.id)
if (roundRequirements.length === 0) return null
return (
<div key={round.id} className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{round.name}</h3>
<Badge variant="outline" className="text-xs">
{roundRequirements.length} requirement{roundRequirements.length !== 1 ? 's' : ''}
</Badge>
</div>
<div className="grid gap-2">
{roundRequirements.map((req: any) => {
// Find file that fulfills this requirement
const fulfilledFile = files?.find((f: any) => f.requirementId === req.id)
const isFulfilled = !!fulfilledFile
return (
<div
key={req.id}
className={`flex items-center justify-between rounded-lg border p-3 ${
isFulfilled
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
: 'border-muted'
}`}
>
<div className="flex items-center gap-3 min-w-0">
{isFulfilled ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
) : (
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{req.name}</p>
{req.isRequired && (
<Badge variant="destructive" className="text-xs shrink-0">
Required
</Badge>
)}
</div>
{req.description && (
<p className="text-xs text-muted-foreground truncate">
{req.description}
</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
{req.acceptedMimeTypes?.length > 0 && (
<span>
{req.acceptedMimeTypes.map((mime: string) => {
if (mime === 'application/pdf') return 'PDF'
if (mime === 'image/*') return 'Images'
if (mime === 'video/*') return 'Video'
if (mime.includes('wordprocessing')) return 'Word'
if (mime.includes('spreadsheet')) return 'Excel'
if (mime.includes('presentation')) return 'PowerPoint'
return mime.split('/')[1] || mime
}).join(', ')}
</span>
)}
{req.maxSizeMB && (
<span className="shrink-0"> Max {req.maxSizeMB}MB</span>
)}
</div>
{isFulfilled && fulfilledFile && (
<p className="text-xs text-green-700 dark:text-green-400 mt-1 font-medium">
{fulfilledFile.fileName}
</p>
)}
</div>
</div>
{!isFulfilled && (
<span className="text-xs text-amber-600 dark:text-amber-400 shrink-0 ml-2 font-medium">
Missing
</span>
)}
</div>
)
})}
</div>
</div>
)
})}
<Separator />
</>
) : null}
{/* General file upload section */}
<div> <div>
<p className="text-sm font-semibold mb-3"> <p className="text-sm font-semibold mb-3">Upload Files</p>
{allRequirements.length > 0 ? 'Additional Documents' : 'Upload Files'}
</p>
<p className="text-xs text-muted-foreground mb-3">
Upload files not tied to specific requirements
</p>
<FileUpload <FileUpload
projectId={projectId} projectId={projectId}
availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))} availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))}
@@ -660,8 +575,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{files && files.length > 0 && ( {files && files.length > 0 && (
<> <>
<Separator /> <Separator />
<div>
<p className="text-sm font-semibold mb-3">All Uploaded Files</p>
<FileViewer <FileViewer
projectId={projectId} projectId={projectId}
files={files.map((f) => ({ files={files.map((f) => ({
@@ -677,9 +590,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
detectedLang: f.detectedLang, detectedLang: f.detectedLang,
langConfidence: f.langConfidence, langConfidence: f.langConfidence,
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null, analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
requirementId: f.requirementId,
requirement: f.requirement ? {
id: f.requirement.id,
name: f.requirement.name,
description: f.requirement.description,
isRequired: f.requirement.isRequired,
} : null,
}))} }))}
/> />
</div>
</> </>
)} )}
</CardContent> </CardContent>
@@ -721,11 +640,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Score</TableHead> <TableHead>Score</TableHead>
<TableHead>Decision</TableHead> <TableHead>Decision</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{assignments.map((assignment) => ( {assignments.map((assignment) => (
<TableRow key={assignment.id}> <TableRow
key={assignment.id}
className={assignment.evaluation?.status === 'SUBMITTED' ? 'cursor-pointer hover:bg-muted/50' : ''}
onClick={() => {
if (assignment.evaluation?.status === 'SUBMITTED') {
setSelectedEvalAssignment(assignment)
}
}}
>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserAvatar <UserAvatar
@@ -799,6 +727,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell>
{assignment.evaluation?.status === 'SUBMITTED' && (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -808,6 +741,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</AnimatedCard> </AnimatedCard>
)} )}
{/* Evaluation Detail Sheet */}
<EvaluationDetailSheet
assignment={selectedEvalAssignment}
open={!!selectedEvalAssignment}
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
/>
{/* AI Evaluation Summary */} {/* AI Evaluation Summary */}
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && ( {assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
<EvaluationSummaryCard <EvaluationSummaryCard
@@ -890,6 +830,173 @@ function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string;
) )
} }
function EvaluationDetailSheet({
assignment,
open,
onOpenChange,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assignment: any
open: boolean
onOpenChange: (open: boolean) => void
}) {
if (!assignment?.evaluation) return null
const ev = assignment.evaluation
const criterionScores = (ev.criterionScoresJson || {}) as Record<string, number | boolean | string>
const hasScores = Object.keys(criterionScores).length > 0
// Try to get the evaluation form for labels
const roundId = assignment.roundId as string | undefined
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId: roundId ?? '' },
{ enabled: !!roundId }
)
// Build label lookup from form criteria
const criteriaMap = new Map<string, { label: string; type: string; trueLabel?: string; falseLabel?: string }>()
if (activeForm?.criteriaJson) {
for (const c of activeForm.criteriaJson as Array<{ id: string; label: string; type?: string; trueLabel?: string; falseLabel?: string }>) {
criteriaMap.set(c.id, {
label: c.label,
type: c.type || 'numeric',
trueLabel: c.trueLabel,
falseLabel: c.falseLabel,
})
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<UserAvatar user={assignment.user} avatarUrl={assignment.user.avatarUrl} size="sm" />
{assignment.user.name || assignment.user.email}
</SheetTitle>
<SheetDescription>
{ev.submittedAt
? `Submitted ${formatDate(ev.submittedAt)}`
: 'Evaluation details'}
</SheetDescription>
</SheetHeader>
<div className="space-y-6 mt-6">
{/* Global stats */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted">
<p className="text-xs text-muted-foreground">Score</p>
<p className="text-2xl font-bold">
{ev.globalScore !== null ? `${ev.globalScore}/10` : '-'}
</p>
</div>
<div className="p-3 rounded-lg bg-muted">
<p className="text-xs text-muted-foreground">Decision</p>
<div className="mt-1">
{ev.binaryDecision !== null ? (
ev.binaryDecision ? (
<div className="flex items-center gap-1.5 text-emerald-600">
<ThumbsUp className="h-5 w-5" />
<span className="font-semibold">Yes</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-red-600">
<ThumbsDown className="h-5 w-5" />
<span className="font-semibold">No</span>
</div>
)
) : (
<span className="text-2xl font-bold">-</span>
)}
</div>
</div>
</div>
{/* Criterion Scores */}
{hasScores && (
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Criterion Scores
</h4>
<div className="space-y-2.5">
{Object.entries(criterionScores).map(([key, value]) => {
const meta = criteriaMap.get(key)
const label = meta?.label || key
const type = meta?.type || (typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'text' : 'numeric')
if (type === 'section_header') return null
if (type === 'boolean') {
return (
<div key={key} className="flex items-center justify-between p-2.5 rounded-lg border">
<span className="text-sm">{label}</span>
{value === true ? (
<Badge className="bg-emerald-100 text-emerald-700 border-emerald-200" variant="outline">
<ThumbsUp className="mr-1 h-3 w-3" />
{meta?.trueLabel || 'Yes'}
</Badge>
) : (
<Badge className="bg-red-100 text-red-700 border-red-200" variant="outline">
<ThumbsDown className="mr-1 h-3 w-3" />
{meta?.falseLabel || 'No'}
</Badge>
)}
</div>
)
}
if (type === 'text') {
return (
<div key={key} className="space-y-1">
<span className="text-sm font-medium">{label}</span>
<div className="text-sm text-muted-foreground p-2.5 rounded-lg border bg-muted/50 whitespace-pre-wrap">
{typeof value === 'string' ? value : String(value)}
</div>
</div>
)
}
// Numeric
return (
<div key={key} className="flex items-center gap-3 p-2.5 rounded-lg border">
<span className="text-sm flex-1 truncate">{label}</span>
<div className="flex items-center gap-2 shrink-0">
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${(Number(value) / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-bold tabular-nums w-8 text-right">
{typeof value === 'number' ? value : '-'}
</span>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Feedback Text */}
{ev.feedbackText && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Feedback
</h4>
<div className="text-sm text-muted-foreground p-3 rounded-lg border bg-muted/30 whitespace-pre-wrap leading-relaxed">
{ev.feedbackText}
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
)
}
export default function ProjectDetailPage({ params }: PageProps) { export default function ProjectDetailPage({ params }: PageProps) {
const { id } = use(params) const { id } = use(params)

View File

@@ -711,13 +711,7 @@ export default function ProjectsPage() {
{data && data.projects.length > 0 && ( {data && data.projects.length > 0 && (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
{Object.entries( {Object.entries(data.statusCounts ?? {})
data.projects.reduce<Record<string, number>>((acc, p) => {
const s = p.status ?? 'SUBMITTED'
acc[s] = (acc[s] || 0) + 1
return acc
}, {})
)
.sort(([a], [b]) => { .sort(([a], [b]) => {
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN'] const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b) return order.indexOf(a) - order.indexOf(b)
@@ -870,7 +864,7 @@ export default function ProjectsPage() {
</TableHead> </TableHead>
<TableHead className="min-w-[280px]">Project</TableHead> <TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Category</TableHead> <TableHead>Category</TableHead>
<TableHead>Stage</TableHead> <TableHead>Program</TableHead>
<TableHead>Tags</TableHead> <TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead> <TableHead>Assignments</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
@@ -913,17 +907,8 @@ export default function ProjectsPage() {
const code = normalizeCountryToCode(project.country) const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country const name = code ? getCountryName(code) : project.country
return flag ? ( return (
<TooltipProvider delayDuration={200}> <span className="text-xs text-muted-foreground/70"> · {flag && <span className="text-sm">{flag}</span>} {name}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
</TooltipTrigger>
<TooltipContent side="top"><p>{name}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
) )
})()} })()}
</p> </p>
@@ -1071,7 +1056,7 @@ export default function ProjectsPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Stage</span> <span className="text-muted-foreground">Program</span>
<span>{project.program?.name ?? 'Unassigned'}</span> <span>{project.program?.name ?? 'Unassigned'}</span>
</div> </div>
{project.competitionCategory && ( {project.competitionCategory && (
@@ -1182,17 +1167,8 @@ export default function ProjectsPage() {
const code = normalizeCountryToCode(project.country) const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country const name = code ? getCountryName(code) : project.country
return flag ? ( return (
<TooltipProvider delayDuration={200}> <span className="text-xs text-muted-foreground/70"> · {flag && <span className="text-sm">{flag}</span>} {name}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
</TooltipTrigger>
<TooltipContent side="top"><p>{name}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
) )
})()} })()}
</CardDescription> </CardDescription>

View File

@@ -28,6 +28,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react' import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
@@ -387,7 +388,12 @@ export default function ProjectPoolPage() {
)} )}
</td> </td>
<td className="p-3 text-sm text-muted-foreground"> <td className="p-3 text-sm text-muted-foreground">
{project.country || '-'} {project.country ? (() => {
const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country
return <>{flag && <span>{flag} </span>}{name}</>
})() : '-'}
</td> </td>
<td className="p-3 text-sm text-muted-foreground"> <td className="p-3 text-sm text-muted-foreground">
{project.submittedAt {project.submittedAt

View File

@@ -1,8 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -72,9 +71,11 @@ function ReportsOverview() {
// Project reporting scope (default: latest program, all rounds) // Project reporting scope (default: latest program, all rounds)
const [selectedValue, setSelectedValue] = useState<string | null>(null) const [selectedValue, setSelectedValue] = useState<string | null>(null)
useEffect(() => {
if (programs?.length && !selectedValue) { if (programs?.length && !selectedValue) {
setSelectedValue(`all:${programs[0].id}`) setSelectedValue(`all:${programs[0].id}`)
} }
}, [programs, selectedValue])
const scopeInput = parseSelection(selectedValue) const scopeInput = parseSelection(selectedValue)
const hasScope = !!scopeInput.roundId || !!scopeInput.programId const hasScope = !!scopeInput.roundId || !!scopeInput.programId
@@ -110,7 +111,7 @@ function ReportsOverview() {
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
const jurorCount = dashStats?.jurorCount ?? 0 const jurorCount = dashStats?.jurorCount ?? 0
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0 const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
const totalEvaluations = dashStats?.totalEvaluations ?? 0 const totalAssignments = dashStats?.totalAssignments ?? 0
const completionRate = dashStats?.completionRate ?? 0 const completionRate = dashStats?.completionRate ?? 0
return ( return (
@@ -178,7 +179,7 @@ function ReportsOverview() {
<p className="text-sm font-medium text-muted-foreground">Evaluations</p> <p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p> <p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{totalEvaluations > 0 {totalAssignments > 0
? `${completionRate}% completion rate` ? `${completionRate}% completion rate`
: 'No assignments yet'} : 'No assignments yet'}
</p> </p>
@@ -355,14 +356,14 @@ function ReportsOverview() {
<TableCell> <TableCell>
<Badge <Badge
variant={ variant={
round.status === 'ACTIVE' round.status === 'ROUND_ACTIVE'
? 'default' ? 'default'
: round.status === 'CLOSED' : round.status === 'ROUND_CLOSED'
? 'secondary' ? 'secondary'
: 'outline' : 'outline'
} }
> >
{round.status} {round.status?.replace('ROUND_', '') || round.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@@ -418,9 +419,11 @@ function StageAnalytics() {
) || [] ) || []
// Set default selected stage // Set default selected stage
useEffect(() => {
if (rounds.length && !selectedValue) { if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id) setSelectedValue(rounds[0].id)
} }
}, [rounds.length, selectedValue])
const queryInput = parseSelection(selectedValue) const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId const hasSelection = !!queryInput.roundId || !!queryInput.programId
@@ -529,9 +532,9 @@ function StageAnalytics() {
<Skeleton className="h-[350px]" /> <Skeleton className="h-[350px]" />
) : scoreDistribution ? ( ) : scoreDistribution ? (
<ScoreDistributionChart <ScoreDistributionChart
data={scoreDistribution.distribution} data={scoreDistribution.distribution ?? []}
averageScore={scoreDistribution.averageScore} averageScore={scoreDistribution.averageScore ?? 0}
totalScores={scoreDistribution.totalScores} totalScores={scoreDistribution.totalScores ?? 0}
/> />
) : null} ) : null}
@@ -654,7 +657,7 @@ function CrossStageTab() {
className="cursor-pointer text-sm py-1.5 px-3" className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(stage.id)} onClick={() => toggleRound(stage.id)}
> >
{stage.programName} - {stage.name} {stage.name}
</Badge> </Badge>
) )
})} })}
@@ -702,9 +705,11 @@ function JurorConsistencyTab() {
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` })) ((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || [] ) || []
useEffect(() => {
if (stages.length && !selectedValue) { if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id) setSelectedValue(stages[0].id)
} }
}, [stages.length, selectedValue])
const queryInput = parseSelection(selectedValue) const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId const hasSelection = !!queryInput.roundId || !!queryInput.programId
@@ -735,7 +740,7 @@ function JurorConsistencyTab() {
))} ))}
{stages.map((stage) => ( {stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}> <SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name} {stage.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -774,9 +779,11 @@ function DiversityTab() {
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` })) ((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || [] ) || []
useEffect(() => {
if (stages.length && !selectedValue) { if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id) setSelectedValue(stages[0].id)
} }
}, [stages.length, selectedValue])
const queryInput = parseSelection(selectedValue) const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId const hasSelection = !!queryInput.roundId || !!queryInput.programId
@@ -807,7 +814,7 @@ function DiversityTab() {
))} ))}
{stages.map((stage) => ( {stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}> <SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name} {stage.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -831,6 +838,97 @@ function DiversityTab() {
) )
} }
function RoundPipelineTab() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
const roundIds = rounds.map(r => r.id)
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds },
{ enabled: roundIds.length >= 2 }
)
if (isLoading || comparisonLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-24" />)}
</div>
)
}
if (!rounds.length) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Layers className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No rounds available</p>
</CardContent>
</Card>
)
}
const comparisonMap = new Map(
(comparison ?? []).map((c: any) => [c.roundId, c])
)
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Layers className="h-4 w-4 text-violet-600" />
</div>
Round Pipeline
</CardTitle>
<CardDescription>Project flow across competition rounds</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{rounds.map((round, idx) => {
const stats = comparisonMap.get(round.id) as any
return (
<div key={round.id} className="flex items-center gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
{idx + 1}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{round.name}</p>
<p className="text-xs text-muted-foreground">{round.programName}</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="tabular-nums">{stats?.projectCount ?? 0} projects</span>
<span className="tabular-nums">{stats?.evaluationCount ?? 0} evals</span>
<Badge variant={round.status === 'ROUND_ACTIVE' ? 'default' : round.status === 'ROUND_CLOSED' ? 'secondary' : 'outline'}>
{round.status?.replace('ROUND_', '') ?? 'DRAFT'}
</Badge>
</div>
</div>
{stats?.completionRate != null && (
<Progress value={stats.completionRate} className="mt-2 h-2" />
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
</div>
)
}
export default function ReportsPage() { export default function ReportsPage() {
const [pdfStageId, setPdfStageId] = useState<string | null>(null) const [pdfStageId, setPdfStageId] = useState<string | null>(null)
@@ -839,9 +937,11 @@ export default function ReportsPage() {
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` })) ((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || [] ) || []
useEffect(() => {
if (pdfStages.length && !pdfStageId) { if (pdfStages.length && !pdfStageId) {
setPdfStageId(pdfStages[0].id) setPdfStageId(pdfStages[0].id)
} }
}, [pdfStages.length, pdfStageId])
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId) const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
@@ -879,11 +979,9 @@ export default function ReportsPage() {
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
Diversity Diversity
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="pipeline" className="gap-2" asChild> <TabsTrigger value="pipeline" className="gap-2">
<Link href={"/admin/reports/stages" as Route}>
<Layers className="h-4 w-4" /> <Layers className="h-4 w-4" />
By Round By Round
</Link>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end"> <div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
@@ -894,7 +992,7 @@ export default function ReportsPage() {
<SelectContent> <SelectContent>
{pdfStages.map((stage) => ( {pdfStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}> <SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name} {stage.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -928,6 +1026,10 @@ export default function ReportsPage() {
<TabsContent value="diversity"> <TabsContent value="diversity">
<DiversityTab /> <DiversityTab />
</TabsContent> </TabsContent>
<TabsContent value="pipeline">
<RoundPipelineTab />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
) )

File diff suppressed because it is too large Load Diff

View File

@@ -95,6 +95,7 @@ type RoundWithStats = {
sortOrder: number sortOrder: number
windowOpenAt: string | null windowOpenAt: string | null
windowCloseAt: string | null windowCloseAt: string | null
specialAwardId: string | null
juryGroup: { id: string; name: string } | null juryGroup: { id: string; name: string } | null
_count: { projectRoundStates: number; assignments: number } _count: { projectRoundStates: number; assignments: number }
} }
@@ -122,18 +123,19 @@ export default function RoundsPage() {
const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({}) const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({})
const [editingCompId, setEditingCompId] = useState<string | null>(null) const [editingCompId, setEditingCompId] = useState<string | null>(null)
const [filterType, setFilterType] = useState<string>('all') const [filterType, setFilterType] = useState<string>('all')
const [selectedCompId, setSelectedCompId] = useState<string | null>(null)
const { data: competitions, isLoading } = trpc.competition.list.useQuery( const { data: competitions, isLoading } = trpc.competition.list.useQuery(
{ programId: programId! }, { programId: programId! },
{ enabled: !!programId } { enabled: !!programId, refetchInterval: 30_000 }
) )
// Use the first (and usually only) competition // Auto-select first competition, or use the user's selection
const comp = competitions?.[0] const comp = competitions?.find((c: any) => c.id === selectedCompId) ?? competitions?.[0]
const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery( const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery(
{ id: comp?.id! }, { id: comp?.id! },
{ enabled: !!comp?.id } { enabled: !!comp?.id, refetchInterval: 30_000 }
) )
const { data: awards } = trpc.specialAward.list.useQuery( const { data: awards } = trpc.specialAward.list.useQuery(
@@ -192,7 +194,7 @@ export default function RoundsPage() {
return return
} }
const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const nextOrder = rounds.length const nextOrder = (compDetail?.rounds ?? []).length
createRoundMutation.mutate({ createRoundMutation.mutate({
competitionId: comp.id, competitionId: comp.id,
name: roundForm.name.trim(), name: roundForm.name.trim(),
@@ -203,14 +205,14 @@ export default function RoundsPage() {
} }
const startEditSettings = () => { const startEditSettings = () => {
if (!comp) return if (!comp || !compDetail) return
setEditingCompId(comp.id) setEditingCompId(comp.id)
setCompetitionEdits({ setCompetitionEdits({
name: comp.name, name: compDetail.name,
categoryMode: (comp as any).categoryMode, categoryMode: compDetail.categoryMode,
startupFinalistCount: (comp as any).startupFinalistCount, startupFinalistCount: compDetail.startupFinalistCount,
conceptFinalistCount: (comp as any).conceptFinalistCount, conceptFinalistCount: compDetail.conceptFinalistCount,
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach, notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
}) })
setSettingsOpen(true) setSettingsOpen(true)
} }
@@ -282,13 +284,30 @@ export default function RoundsPage() {
// ─── Main Render ───────────────────────────────────────────────────────── // ─── Main Render ─────────────────────────────────────────────────────────
const activeFilter = filterType !== 'all' const activeFilter = filterType !== 'all'
const totalProjects = rounds.reduce((s, r) => s + r._count.projectRoundStates, 0) const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
const totalAssignments = rounds.reduce((s, r) => s + r._count.assignments, 0) const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
const activeRound = rounds.find((r) => r.status === 'ROUND_ACTIVE') const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
const activeRound = allRounds.find((r) => r.status === 'ROUND_ACTIVE')
return ( return (
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<div className="space-y-5"> <div className="space-y-5">
{/* Competition selector (when multiple exist) */}
{competitions && competitions.length > 1 && (
<Select value={comp.id} onValueChange={setSelectedCompId}>
<SelectTrigger className="w-[280px] mb-4">
<SelectValue placeholder="Select competition" />
</SelectTrigger>
<SelectContent>
{competitions.map((c: any) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* ── Header Bar ──────────────────────────────────────────────── */} {/* ── Header Bar ──────────────────────────────────────────────── */}
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
@@ -309,7 +328,7 @@ export default function RoundsPage() {
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground"> <div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span>{rounds.length} rounds</span> <span>{allRounds.filter((r) => !r.specialAwardId).length} rounds</span>
<span className="text-muted-foreground/30">|</span> <span className="text-muted-foreground/30">|</span>
<span>{totalProjects} projects</span> <span>{totalProjects} projects</span>
<span className="text-muted-foreground/30">|</span> <span className="text-muted-foreground/30">|</span>
@@ -476,7 +495,7 @@ export default function RoundsPage() {
{projectCount} {projectCount}
</span> </span>
{assignmentCount > 0 && ( {assignmentCount > 0 && (
<span className="tabular-nums">{assignmentCount} eval</span> <span className="tabular-nums">{assignmentCount} asgn</span>
)} )}
{(round.windowOpenAt || round.windowCloseAt) && ( {(round.windowOpenAt || round.windowCloseAt) && (
<span className="flex items-center gap-1 tabular-nums"> <span className="flex items-center gap-1 tabular-nums">

View File

@@ -12,6 +12,7 @@ export default async function AdminLayout({
// Fetch all editions (programs) for the edition selector // Fetch all editions (programs) for the edition selector
const editions = await prisma.program.findMany({ const editions = await prisma.program.findMany({
where: { isTest: false },
select: { select: {
id: true, id: true,
name: true, name: true,

View File

@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import Image from 'next/image' import Image from 'next/image'
import { auth } from '@/lib/auth' import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export default async function AuthLayout({ export default async function AuthLayout({
children, children,
@@ -18,6 +19,13 @@ export default async function AuthLayout({
// Redirect logged-in users to their dashboard // Redirect logged-in users to their dashboard
// But NOT if they still need to set their password // But NOT if they still need to set their password
if (session?.user && !session.user.mustSetPassword) { if (session?.user && !session.user.mustSetPassword) {
// Verify user still exists in DB (handles deleted accounts with stale sessions)
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true },
})
if (dbUser) {
const role = session.user.role const role = session.user.role
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') { if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
redirect('/admin') redirect('/admin')
@@ -29,6 +37,8 @@ export default async function AuthLayout({
redirect('/mentor') redirect('/mentor')
} }
} }
// If user doesn't exist in DB, fall through and show auth page
}
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">

View File

@@ -1,27 +1,22 @@
'use client' 'use client'
import { use, useState, useEffect } from 'react' import { use, useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { import { cn } from '@/lib/utils'
Dialog, import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
DialogContent, import { Badge } from '@/components/ui/badge'
DialogDescription, import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
DialogFooter, import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react'
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs' import type { EvaluationConfig } from '@/types/competition-configs'
@@ -35,15 +30,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const { roundId, projectId } = params const { roundId, projectId } = params
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [showCOIDialog, setShowCOIDialog] = useState(true) // Evaluation form state — stores all criterion values (numeric, boolean, text)
const [coiAccepted, setCoiAccepted] = useState(false) const [criteriaValues, setCriteriaValues] = useState<Record<string, number | boolean | string>>({})
// Evaluation form state
const [criteriaScores, setCriteriaScores] = useState<Record<string, number>>({})
const [globalScore, setGlobalScore] = useState('') const [globalScore, setGlobalScore] = useState('')
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('') const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
const [feedbackText, setFeedbackText] = useState('') const [feedbackText, setFeedbackText] = useState('')
// Track dirty state for autosave
const isDirtyRef = useRef(false)
const evaluationIdRef = useRef<string | null>(null)
const isSubmittedRef = useRef(false)
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
// Fetch project // Fetch project
const { data: project } = trpc.project.get.useQuery( const { data: project } = trpc.project.get.useQuery(
{ id: projectId }, { id: projectId },
@@ -70,7 +69,15 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{ enabled: !!myAssignment?.id } { enabled: !!myAssignment?.id }
) )
// Fetch the active evaluation form for this round (independent of evaluation existence) // COI (Conflict of Interest) check
const { data: coiStatus, isLoading: coiLoading } = trpc.evaluation.getCOIStatus.useQuery(
{ assignmentId: myAssignment?.id ?? '' },
{ enabled: !!myAssignment?.id }
)
const [coiCompleted, setCOICompleted] = useState(false)
const [coiHasConflict, setCOIHasConflict] = useState(false)
// Fetch the active evaluation form for this round
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId }, { roundId },
{ enabled: !!roundId } { enabled: !!roundId }
@@ -79,17 +86,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
// Start evaluation mutation (creates draft) // Start evaluation mutation (creates draft)
const startMutation = trpc.evaluation.start.useMutation() const startMutation = trpc.evaluation.start.useMutation()
// Autosave mutation // Autosave mutation (silent)
const autosaveMutation = trpc.evaluation.autosave.useMutation({ const autosaveMutation = trpc.evaluation.autosave.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success('Draft saved', { duration: 1500 }) isDirtyRef.current = false
setLastSavedAt(new Date())
}, },
onError: (err) => toast.error(err.message),
}) })
// Submit mutation // Submit mutation
const submitMutation = trpc.evaluation.submit.useMutation({ const submitMutation = trpc.evaluation.submit.useMutation({
onSuccess: () => { onSuccess: () => {
isSubmittedRef.current = true
isDirtyRef.current = false
utils.roundAssignment.getMyAssignments.invalidate() utils.roundAssignment.getMyAssignments.invalidate()
utils.evaluation.get.invalidate() utils.evaluation.get.invalidate()
toast.success('Evaluation submitted successfully') toast.success('Evaluation submitted successfully')
@@ -98,15 +107,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
// Track evaluation ID
useEffect(() => {
if (existingEvaluation?.id) {
evaluationIdRef.current = existingEvaluation.id
}
}, [existingEvaluation?.id])
// Load existing evaluation data // Load existing evaluation data
useEffect(() => { useEffect(() => {
if (existingEvaluation) { if (existingEvaluation) {
if (existingEvaluation.criterionScoresJson) { if (existingEvaluation.criterionScoresJson) {
const scores: Record<string, number> = {} const values: Record<string, number | boolean | string> = {}
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => { Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
scores[key] = typeof value === 'number' ? value : 0 if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
values[key] = value
}
}) })
setCriteriaScores(scores) setCriteriaValues(values)
} }
if (existingEvaluation.globalScore) { if (existingEvaluation.globalScore) {
setGlobalScore(existingEvaluation.globalScore.toString()) setGlobalScore(existingEvaluation.globalScore.toString())
@@ -117,6 +135,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (existingEvaluation.feedbackText) { if (existingEvaluation.feedbackText) {
setFeedbackText(existingEvaluation.feedbackText) setFeedbackText(existingEvaluation.feedbackText)
} }
isDirtyRef.current = false
} }
}, [existingEvaluation]) }, [existingEvaluation])
@@ -126,12 +145,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const requireFeedback = evalConfig?.requireFeedback ?? true const requireFeedback = evalConfig?.requireFeedback ?? true
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10 const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
// Get criteria from the active evaluation form (independent of evaluation record) // Parse criteria from the active form
const criteria = (activeForm?.criteriaJson ?? []).map((c) => { const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
// Parse scale string like "1-10" into minScore/maxScore const type = (c as any).type || 'numeric'
let minScore = 1 let minScore = 1
let maxScore = 10 let maxScore = 10
if (c.scale) { if (type === 'numeric' && c.scale) {
const parts = c.scale.split('-').map(Number) const parts = c.scale.split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
minScore = parts[0] minScore = parts[0]
@@ -142,33 +161,135 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
id: c.id, id: c.id,
label: c.label, label: c.label,
description: c.description, description: c.description,
type: type as 'numeric' | 'text' | 'boolean' | 'section_header',
weight: c.weight, weight: c.weight,
minScore, minScore,
maxScore, maxScore,
required: (c as any).required ?? true,
trueLabel: (c as any).trueLabel || 'Yes',
falseLabel: (c as any).falseLabel || 'No',
maxLength: (c as any).maxLength || 1000,
placeholder: (c as any).placeholder || '',
} }
}) })
// Build current form data for autosave
const buildSavePayload = useCallback(() => {
return {
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : undefined,
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
feedbackText: feedbackText || null,
}
}, [scoringMode, criteriaValues, globalScore, binaryDecision, feedbackText])
// Perform autosave
const performAutosave = useCallback(async () => {
if (!isDirtyRef.current || isSubmittedRef.current) return
if (existingEvaluation?.status === 'SUBMITTED') return
let evalId = evaluationIdRef.current
if (!evalId && myAssignment) {
try {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evalId = newEval.id
evaluationIdRef.current = evalId
} catch {
return
}
}
if (!evalId) return
autosaveMutation.mutate({ id: evalId, ...buildSavePayload() })
}, [myAssignment, existingEvaluation?.status, startMutation, autosaveMutation, buildSavePayload])
// Debounced autosave: save 3 seconds after last change
useEffect(() => {
if (!isDirtyRef.current) return
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current)
}
autosaveTimerRef.current = setTimeout(() => {
performAutosave()
}, 3000)
return () => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current)
}
}
}, [criteriaValues, globalScore, binaryDecision, feedbackText, performAutosave])
// Save on page leave (beforeunload)
useEffect(() => {
const handleBeforeUnload = () => {
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
const payload = JSON.stringify({
id: evaluationIdRef.current,
...buildSavePayload(),
})
navigator.sendBeacon?.('/api/trpc/evaluation.autosave', payload)
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [buildSavePayload])
// Save on component unmount (navigating away within the app)
useEffect(() => {
return () => {
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
performAutosave()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Mark dirty when form values change
const handleCriterionChange = (key: string, value: number | boolean | string) => {
setCriteriaValues((prev) => ({ ...prev, [key]: value }))
isDirtyRef.current = true
}
const handleGlobalScoreChange = (value: string) => {
setGlobalScore(value)
isDirtyRef.current = true
}
const handleBinaryChange = (value: 'accept' | 'reject') => {
setBinaryDecision(value)
isDirtyRef.current = true
}
const handleFeedbackChange = (value: string) => {
setFeedbackText(value)
isDirtyRef.current = true
}
const handleSaveDraft = async () => { const handleSaveDraft = async () => {
if (!myAssignment) { if (!myAssignment) {
toast.error('Assignment not found') toast.error('Assignment not found')
return return
} }
// Create evaluation if it doesn't exist let evaluationId = evaluationIdRef.current
let evaluationId = existingEvaluation?.id
if (!evaluationId) { if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id }) const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id evaluationId = newEval.id
evaluationIdRef.current = evaluationId
} }
// Autosave current state autosaveMutation.mutate(
autosaveMutation.mutate({ { id: evaluationId, ...buildSavePayload() },
id: evaluationId, { onSuccess: () => {
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : undefined, isDirtyRef.current = false
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null, setLastSavedAt(new Date())
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null, toast.success('Draft saved', { duration: 1500 })
feedbackText: feedbackText || null, }}
}) )
} }
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -177,17 +298,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
return return
} }
// Validation based on scoring mode // Validation for criteria mode
if (scoringMode === 'criteria') { if (scoringMode === 'criteria') {
if (!criteria || criteria.length === 0) { const requiredCriteria = criteria.filter((c) =>
toast.error('No criteria found for this evaluation') c.type !== 'section_header' && c.required
)
for (const c of requiredCriteria) {
const val = criteriaValues[c.id]
if (c.type === 'numeric' && (val === undefined || val === null)) {
toast.error(`Please score "${c.label}"`)
return return
} }
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false if (c.type === 'boolean' && val === undefined) {
if (requiredCriteria) { toast.error(`Please answer "${c.label}"`)
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined) return
if (!allScored) { }
toast.error('Please score all criteria') if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
toast.error(`Please fill in "${c.label}"`)
return return
} }
} }
@@ -215,76 +342,43 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
} }
} }
// Create evaluation if needed let evaluationId = evaluationIdRef.current
let evaluationId = existingEvaluation?.id
if (!evaluationId) { if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id }) const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id evaluationId = newEval.id
evaluationIdRef.current = evaluationId
}
// Compute a weighted global score from numeric criteria for the global score field
const numericCriteria = criteria.filter((c) => c.type === 'numeric')
let computedGlobalScore = 5
if (scoringMode === 'criteria' && numericCriteria.length > 0) {
let totalWeight = 0
let weightedSum = 0
for (const c of numericCriteria) {
const val = criteriaValues[c.id]
if (typeof val === 'number') {
const w = c.weight ?? 1
// Normalize to 1-10 scale
const normalized = ((val - c.minScore) / (c.maxScore - c.minScore)) * 9 + 1
weightedSum += normalized * w
totalWeight += w
}
}
if (totalWeight > 0) {
computedGlobalScore = Math.round(weightedSum / totalWeight)
}
} }
// Submit
submitMutation.mutate({ submitMutation.mutate({
id: evaluationId, id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {}, criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5, globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true, binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
feedbackText: feedbackText || 'No feedback provided', feedbackText: feedbackText || 'No feedback provided',
}) })
} }
// COI Dialog
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
return (
<Dialog open={showCOIDialog} onOpenChange={() => { /* prevent dismissal */ }}>
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Conflict of Interest Declaration</DialogTitle>
<DialogDescription className="space-y-3 pt-2">
<p>
Before evaluating this project, you must confirm that you have no conflict of
interest.
</p>
<p>
A conflict of interest exists if you have a personal, professional, or financial
relationship with the project team that could influence your judgment.
</p>
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-3 py-4">
<Checkbox
id="coi"
checked={coiAccepted}
onCheckedChange={(checked) => setCoiAccepted(checked as boolean)}
/>
<Label htmlFor="coi" className="text-sm leading-relaxed cursor-pointer">
I confirm that I have no conflict of interest with this project and can provide an
unbiased evaluation.
</Label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}` as Route)}
>
Cancel
</Button>
<Button
onClick={() => setShowCOIDialog(false)}
disabled={!coiAccepted}
className="bg-brand-blue hover:bg-brand-blue-light"
>
Continue to Evaluation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
if (!round || !project) { if (!round || !project) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -300,6 +394,123 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
) )
} }
// COI config
const coiRequired = evalConfig?.coiRequired ?? true
// Determine COI state: declared via server or just completed in this session
// coiStatus is null when no COI record exists, truthy when declared
const coiDeclared = coiCompleted || (coiStatus != null)
const coiConflict = coiHasConflict || (coiStatus?.hasConflict ?? false)
// Check if round is active
const isRoundActive = round.status === 'ROUND_ACTIVE'
if (!isRoundActive) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
</div>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
<Clock className="h-6 w-6 text-amber-600" />
</div>
<div>
<h2 className="text-lg font-semibold">Evaluation Not Available</h2>
<p className="text-sm text-muted-foreground mt-1">
This round is not currently active. Evaluations can only be submitted during an active round.
</p>
<Button variant="outline" size="sm" className="mt-4" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
View Project Details
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
// COI gate: if COI is required, not yet declared, and we have an assignment
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">{project.title}</p>
</div>
</div>
<COIDeclarationDialog
open={true}
assignmentId={myAssignment.id}
projectTitle={project.title}
onComplete={(hasConflict) => {
setCOICompleted(true)
setCOIHasConflict(hasConflict)
}}
/>
</div>
)
}
// COI conflict declared — block evaluation
if (coiRequired && coiConflict) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">{project.title}</p>
</div>
</div>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
<ShieldAlert className="h-6 w-6 text-amber-600" />
</div>
<div>
<h2 className="text-lg font-semibold">Conflict of Interest Declared</h2>
<p className="text-sm text-muted-foreground mt-1">
You declared a conflict of interest for this project. An administrator will
review your declaration. You cannot evaluate this project while the conflict
is under review.
</p>
<Button variant="outline" size="sm" className="mt-4" asChild>
<Link href={`/jury/competitions/${roundId}` as Route}>
Back to Round
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -313,9 +524,26 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project Evaluate Project
</h1> </h1>
<p className="text-muted-foreground mt-1">{project.title}</p> <div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground">{project.title}</p>
{project.competitionCategory && (
<Badge
variant="secondary"
className={
project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
}
>
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
)}
</div> </div>
</div> </div>
</div>
{/* Project Documents */}
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
<Card className="border-l-4 border-l-amber-500"> <Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
@@ -324,7 +552,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<p className="font-medium text-sm">Important Reminder</p> <p className="font-medium text-sm">Important Reminder</p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Your evaluation will be used to assess this project. Please provide thoughtful and Your evaluation will be used to assess this project. Please provide thoughtful and
constructive feedback to help the team improve. constructive feedback. Your progress is automatically saved as a draft.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -332,64 +560,218 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Evaluation Form</CardTitle> <CardTitle>Evaluation Form</CardTitle>
<CardDescription> <CardDescription>
Provide your assessment using the {scoringMode} scoring method {scoringMode === 'criteria'
? 'Complete all required fields below'
: `Provide your assessment using the ${scoringMode} scoring method`}
</CardDescription> </CardDescription>
</div>
{lastSavedAt && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
Saved {lastSavedAt.toLocaleTimeString()}
</span>
)}
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Criteria-based scoring */} {/* Criteria-based scoring with mixed types */}
{scoringMode === 'criteria' && criteria && criteria.length > 0 && ( {scoringMode === 'criteria' && criteria && criteria.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold">Criteria Scores</h3> {criteria.map((criterion) => {
{criteria.map((criterion) => ( if (criterion.type === 'section_header') {
<div key={criterion.id} className="space-y-2 p-4 border rounded-lg"> return (
<Label htmlFor={criterion.id}> <div key={criterion.id} className="border-b pb-2 pt-4 first:pt-0">
{criterion.label} <h3 className="font-semibold text-lg">{criterion.label}</h3>
{evalConfig?.requireAllCriteriaScored !== false && ( {criterion.description && (
<span className="text-destructive ml-1">*</span> <p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
)} )}
</div>
)
}
if (criterion.type === 'boolean') {
const currentValue = criteriaValues[criterion.id]
return (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="space-y-1">
<Label className="text-base font-medium">
{criterion.label}
{criterion.required && <span className="text-destructive ml-1">*</span>}
</Label> </Label>
{criterion.description && ( {criterion.description && (
<p className="text-xs text-muted-foreground">{criterion.description}</p> <p className="text-sm text-muted-foreground">{criterion.description}</p>
)} )}
<Input
id={criterion.id}
type="number"
min={criterion.minScore ?? 0}
max={criterion.maxScore ?? 10}
value={criteriaScores[criterion.id] ?? ''}
onChange={(e) =>
setCriteriaScores({
...criteriaScores,
[criterion.id]: parseInt(e.target.value, 10) || 0,
})
}
placeholder={`Score (${criterion.minScore ?? 0}-${criterion.maxScore ?? 10})`}
/>
</div> </div>
<div className="flex gap-3">
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
)}
>
<ThumbsUp className="mr-2 h-4 w-4" />
{criterion.trueLabel}
</button>
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
: 'border-border hover:border-red-300 hover:bg-red-50/50'
)}
>
<ThumbsDown className="mr-2 h-4 w-4" />
{criterion.falseLabel}
</button>
</div>
</div>
)
}
if (criterion.type === 'text') {
const currentValue = (criteriaValues[criterion.id] as string) || ''
return (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="space-y-1">
<Label className="text-base font-medium">
{criterion.label}
{criterion.required && <span className="text-destructive ml-1">*</span>}
</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
</div>
<Textarea
value={currentValue}
onChange={(e) => handleCriterionChange(criterion.id, e.target.value)}
placeholder={criterion.placeholder || 'Enter your response...'}
rows={4}
maxLength={criterion.maxLength}
/>
<p className="text-xs text-muted-foreground text-right">
{currentValue.length}/{criterion.maxLength}
</p>
</div>
)
}
// Default: numeric criterion
const min = criterion.minScore ?? 1
const max = criterion.maxScore ?? 10
const currentValue = criteriaValues[criterion.id]
const displayValue = typeof currentValue === 'number' ? currentValue : undefined
const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
return (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-base font-medium">
{criterion.label}
{criterion.required && <span className="text-destructive ml-1">*</span>}
</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
</div>
<span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
{displayValue !== undefined ? displayValue : '\u2014'}/{max}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">{min}</span>
<Slider
min={min}
max={max}
step={1}
value={[sliderValue]}
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-4">{max}</span>
</div>
<div className="flex gap-1 flex-wrap">
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map((num) => (
<button
key={num}
type="button"
onClick={() => handleCriterionChange(criterion.id, num)}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
displayValue !== undefined && displayValue === num
? 'bg-primary text-primary-foreground'
: displayValue !== undefined && displayValue > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80'
)}
>
{num}
</button>
))} ))}
</div> </div>
</div>
)
})}
</div>
)} )}
{/* Global scoring */} {/* Global scoring */}
{scoringMode === 'global' && ( {scoringMode === 'global' && (
<div className="space-y-2"> <div className="space-y-3">
<Label htmlFor="globalScore"> <div className="flex items-center justify-between">
<Label>
Overall Score <span className="text-destructive">*</span> Overall Score <span className="text-destructive">*</span>
</Label> </Label>
<Input <span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
id="globalScore" {globalScore || '\u2014'}/10
type="number" </span>
min="1" </div>
max="10" <div className="flex items-center gap-2">
value={globalScore} <span className="text-xs text-muted-foreground">1</span>
onChange={(e) => setGlobalScore(e.target.value)} <Slider
placeholder="Enter score (1-10)" min={1}
max={10}
step={1}
value={[globalScore ? parseInt(globalScore, 10) : 5]}
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
className="flex-1"
/> />
<p className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">10</span>
Provide a score from 1 to 10 based on your overall assessment </div>
</p> <div className="flex gap-1 flex-wrap">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => {
const current = globalScore ? parseInt(globalScore, 10) : 0
return (
<button
key={num}
type="button"
onClick={() => handleGlobalScoreChange(num.toString())}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
current === num
? 'bg-primary text-primary-foreground'
: current > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80'
)}
>
{num}
</button>
)
})}
</div>
</div> </div>
)} )}
@@ -399,7 +781,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Label> <Label>
Decision <span className="text-destructive">*</span> Decision <span className="text-destructive">*</span>
</Label> </Label>
<RadioGroup value={binaryDecision} onValueChange={(v) => setBinaryDecision(v as 'accept' | 'reject')}> <RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}>
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50"> <div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
<RadioGroupItem value="accept" id="accept" /> <RadioGroupItem value="accept" id="accept" />
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1"> <Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
@@ -421,13 +803,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{/* Feedback */} {/* Feedback */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="feedbackText"> <Label htmlFor="feedbackText">
Feedback General Comment / Feedback
{requireFeedback && <span className="text-destructive ml-1">*</span>} {requireFeedback && <span className="text-destructive ml-1">*</span>}
</Label> </Label>
<Textarea <Textarea
id="feedbackText" id="feedbackText"
value={feedbackText} value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)} onChange={(e) => handleFeedbackChange(e.target.value)}
placeholder="Provide your feedback on the project..." placeholder="Provide your feedback on the project..."
rows={8} rows={8}
/> />

View File

@@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer' import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { ArrowLeft, FileText, Users, MapPin, Target } from 'lucide-react' import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryProjectDetailPage() { export default function JuryProjectDetailPage() {
const params = useParams() const params = useParams()
@@ -22,6 +21,14 @@ export default function JuryProjectDetailPage() {
{ enabled: !!projectId } { enabled: !!projectId }
) )
const { data: round } = trpc.round.getById.useQuery(
{ id: roundId },
{ enabled: !!roundId }
)
// Round status is the primary gate for evaluations
const isVotingOpen = round?.status === 'ROUND_ACTIVE'
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -71,34 +78,75 @@ export default function JuryProjectDetailPage() {
<p className="text-muted-foreground mt-1">{project.teamName}</p> <p className="text-muted-foreground mt-1">{project.teamName}</p>
)} )}
</div> </div>
{isVotingOpen ? (
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light"> <Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}> <Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}>
<Target className="mr-2 h-4 w-4" /> <Target className="mr-2 h-4 w-4" />
Evaluate Project Evaluate Project
</Link> </Link>
</Button> </Button>
) : (
<Badge variant="outline" className="text-muted-foreground">
Voting not open
</Badge>
)}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Project metadata */} {/* Project metadata */}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{project.competitionCategory && (
<Badge
variant="secondary"
className={
project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
}
>
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
)}
{project.country && ( {project.country && (
<Badge variant="outline" className="gap-1"> <Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" /> <MapPin className="h-3 w-3" />
{project.country} {project.country}
</Badge> </Badge>
)} )}
{project.competitionCategory && ( </div>
<Badge variant="outline">{project.competitionCategory}</Badge>
{/* Project tags */}
{((project.projectTags && project.projectTags.length > 0) ||
(project.tags && project.tags.length > 0)) && (
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Tag className="h-4 w-4" />
Tags
</h3>
<div className="flex flex-wrap gap-2">
{project.projectTags && project.projectTags.length > 0
? project.projectTags.map((pt: any) => (
<Badge
key={pt.id}
variant="secondary"
style={pt.tag.color ? { backgroundColor: pt.tag.color + '20', borderColor: pt.tag.color, color: pt.tag.color } : undefined}
className="text-xs"
>
{pt.tag.name}
{pt.tag.category && (
<span className="ml-1 opacity-60">({pt.tag.category})</span>
)} )}
{project.tags && project.tags.length > 0 && ( </Badge>
project.tags.slice(0, 3).map((tag: string) => ( ))
<Badge key={tag} variant="secondary"> : project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag} {tag}
</Badge> </Badge>
)) ))
)} }
</div> </div>
</div>
)}
{/* Description */} {/* Description */}
{project.description && ( {project.description && (

View File

@@ -31,7 +31,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
votes.forEach((vote) => { votes.forEach((vote) => {
submitVoteMutation.mutate({ submitVoteMutation.mutate({
sessionId: params.sessionId, sessionId: params.sessionId,
juryMemberId: session?.currentUser?.id || '', juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
projectId: vote.projectId, projectId: vote.projectId,
rank: vote.rank, rank: vote.rank,
isWinnerPick: vote.isWinnerPick isWinnerPick: vote.isWinnerPick
@@ -63,9 +63,9 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
); );
} }
const hasVoted = session.currentUser?.hasVoted; const hasVoted = false; // TODO: check if current user has voted in this session
if (session.status !== 'DELIB_VOTING') { if (session.status !== 'VOTING') {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
@@ -79,7 +79,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{session.status === 'DELIB_OPEN' {session.status === 'DELIB_OPEN'
? 'Voting has not started yet. Please wait for the admin to open voting.' ? 'Voting has not started yet. Please wait for the admin to open voting.'
: session.status === 'DELIB_TALLYING' : session.status === 'TALLYING'
? 'Voting is closed. Results are being tallied.' ? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'} : 'This session is locked.'}
</p> </p>
@@ -140,7 +140,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
</Card> </Card>
<DeliberationRankingForm <DeliberationRankingForm
projects={session.projects || []} projects={session.results?.map((r) => r.project) ?? []}
mode={session.mode} mode={session.mode}
onSubmit={handleSubmitVote} onSubmit={handleSubmitVote}
disabled={submitVoteMutation.isPending} disabled={submitVoteMutation.isPending}

View File

@@ -58,7 +58,7 @@ export default function JuryAssignmentsPage() {
Projects assigned to you for evaluation Projects assigned to you for evaluation
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
<Link href={'/jury' as Route}> <Link href={'/jury' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard Back to Dashboard
@@ -74,7 +74,7 @@ export default function JuryAssignmentsPage() {
</div> </div>
<h2 className="text-xl font-semibold mb-2">No Assignments</h2> <h2 className="text-xl font-semibold mb-2">No Assignments</h2>
<p className="text-muted-foreground text-center max-w-md"> <p className="text-muted-foreground text-center max-w-md">
You don&apos;t have any active assignments yet. You don&apos;t have any assignments yet. Assignments will appear once an administrator assigns projects to you.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -89,25 +89,26 @@ export default function JuryAssignmentsPage() {
return ( return (
<Card key={round.id}> <Card key={round.id}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-3">
<CardTitle className="text-base">{round.name}</CardTitle> <CardTitle className="text-base">{round.name}</CardTitle>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs shrink-0">
{formatEnumLabel(round.roundType)} {formatEnumLabel(round.roundType)}
</Badge> </Badge>
</div> {round.status !== 'ROUND_ACTIVE' && (
<div className="flex items-center gap-2"> <Badge variant="outline" className="text-xs text-muted-foreground shrink-0">
<span className="text-xs text-muted-foreground"> {formatEnumLabel(round.status)}
</Badge>
)}
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{completed}/{total} completed {completed}/{total} completed
</span> </span>
{round.windowCloseAt && ( {round.windowCloseAt && (
<Badge variant="outline" className="text-xs gap-1"> <Badge variant="outline" className="text-xs gap-1 shrink-0">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
Due {formatDateOnly(round.windowCloseAt)} Due {formatDateOnly(round.windowCloseAt)}
</Badge> </Badge>
)} )}
</div> </div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="divide-y"> <div className="divide-y">

View File

@@ -3,6 +3,7 @@ import { Suspense } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { auth } from '@/lib/auth' import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { AutoRefresh } from '@/components/shared/auto-refresh'
export const metadata: Metadata = { title: 'Jury Dashboard' } export const metadata: Metadata = { title: 'Jury Dashboard' }
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -24,7 +25,6 @@ import {
GitCompare, GitCompare,
Zap, Zap,
BarChart3, BarChart3,
Target,
Waves, Waves,
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
@@ -48,8 +48,8 @@ async function JuryDashboardContent() {
return null return null
} }
// Get assignments and grace periods in parallel // Get assignments, grace periods, and feature flags in parallel
const [assignments, gracePeriods] = await Promise.all([ const [assignments, gracePeriods, compareFlag] = await Promise.all([
prisma.assignment.findMany({ prisma.assignment.findMany({
where: { userId }, where: { userId },
include: { include: {
@@ -107,8 +107,11 @@ async function JuryDashboardContent() {
extendedUntil: true, extendedUntil: true,
}, },
}), }),
prisma.systemSettings.findUnique({ where: { key: 'jury_compare_enabled' } }),
]) ])
const juryCompareEnabled = compareFlag?.value === 'true'
// Calculate stats // Calculate stats
const totalAssignments = assignments.length const totalAssignments = assignments.length
const completedAssignments = assignments.filter( const completedAssignments = assignments.filter(
@@ -187,36 +190,28 @@ async function JuryDashboardContent() {
const stats = [ const stats = [
{ {
label: 'Total Assignments',
value: totalAssignments, value: totalAssignments,
icon: ClipboardList, label: 'Assigned',
accentColor: 'border-l-blue-500', detail: 'Total projects',
iconBg: 'bg-blue-50 dark:bg-blue-950/40', accent: 'text-brand-blue',
iconColor: 'text-blue-600 dark:text-blue-400',
}, },
{ {
label: 'Completed',
value: completedAssignments, value: completedAssignments,
icon: CheckCircle2, label: 'Completed',
accentColor: 'border-l-emerald-500', detail: `${completionRate.toFixed(0)}% done`,
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40', accent: 'text-emerald-600',
iconColor: 'text-emerald-600 dark:text-emerald-400',
}, },
{ {
label: 'In Progress',
value: inProgressAssignments, value: inProgressAssignments,
icon: Clock, label: 'In draft',
accentColor: 'border-l-amber-500', detail: inProgressAssignments > 0 ? 'Work in progress' : 'None started',
iconBg: 'bg-amber-50 dark:bg-amber-950/40', accent: inProgressAssignments > 0 ? 'text-amber-600' : 'text-emerald-600',
iconColor: 'text-amber-600 dark:text-amber-400',
}, },
{ {
label: 'Pending',
value: pendingAssignments, value: pendingAssignments,
icon: Target, label: 'Pending',
accentColor: 'border-l-slate-400', detail: pendingAssignments > 0 ? 'Not yet started' : 'All started',
iconBg: 'bg-slate-50 dark:bg-slate-800/50', accent: pendingAssignments > 0 ? 'text-amber-600' : 'text-emerald-600',
iconColor: 'text-slate-500 dark:text-slate-400',
}, },
] ]
@@ -236,7 +231,7 @@ async function JuryDashboardContent() {
Your project assignments will appear here once an administrator assigns them to you. Your project assignments will appear here once an administrator assigns them to you.
</p> </p>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto"> <div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link <Link
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5" className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
@@ -249,6 +244,7 @@ async function JuryDashboardContent() {
<p className="text-xs text-muted-foreground">View evaluations</p> <p className="text-xs text-muted-foreground">View evaluations</p>
</div> </div>
</Link> </Link>
{juryCompareEnabled && (
<Link <Link
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md" className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
@@ -261,6 +257,7 @@ async function JuryDashboardContent() {
<p className="text-xs text-muted-foreground">Side-by-side view</p> <p className="text-xs text-muted-foreground">Side-by-side view</p>
</div> </div>
</Link> </Link>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -301,48 +298,34 @@ async function JuryDashboardContent() {
</AnimatedCard> </AnimatedCard>
)} )}
{/* Stats + Overall Completion in one row */} {/* Stats — editorial strip */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5"> <AnimatedCard index={1}>
{stats.map((stat, i) => ( {/* Mobile: compact horizontal data strip */}
<AnimatedCard key={stat.label} index={i + 1}> <div className="flex items-baseline justify-between border-b border-t py-3 md:hidden">
<Card className={cn( {stats.map((s, i) => (
'border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', <div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
stat.accentColor, <span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
)}> <p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
<CardContent className="flex items-center gap-4 py-5 px-5">
<div className={cn('rounded-xl p-3', stat.iconBg)}>
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
</div> </div>
<div>
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
</div>
</CardContent>
</Card>
</AnimatedCard>
))} ))}
{/* Overall completion as 5th stat card */}
<AnimatedCard index={5}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="flex items-center gap-4 py-5 px-5">
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
</div> </div>
<div className="flex-1 min-w-0">
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal"> {/* Desktop: editorial stat row */}
{completionRate.toFixed(0)}% <div className="hidden md:block">
</p> <div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/60 mt-1"> {stats.map((s, i) => (
<div <div
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out" key={i}
style={{ width: `${completionRate}%` }} className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
/> >
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
</div>
))}
</div> </div>
</div> </div>
</CardContent>
</Card>
</AnimatedCard> </AnimatedCard>
</div>
{/* Main content -- two column layout */} {/* Main content -- two column layout */}
<div className="grid gap-4 lg:grid-cols-12"> <div className="grid gap-4 lg:grid-cols-12">
@@ -374,12 +357,7 @@ async function JuryDashboardContent() {
const evaluation = assignment.evaluation const evaluation = assignment.evaluation
const isCompleted = evaluation?.status === 'SUBMITTED' const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT' const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen = const isVotingOpen = assignment.round.status === 'ROUND_ACTIVE'
assignment.round.status === 'ROUND_ACTIVE' &&
assignment.round.windowOpenAt &&
assignment.round.windowCloseAt &&
new Date(assignment.round.windowOpenAt) <= now &&
new Date(assignment.round.windowCloseAt) >= now
return ( return (
<div <div
@@ -473,7 +451,7 @@ async function JuryDashboardContent() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-3 sm:grid-cols-2"> <div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link <Link
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5" className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
@@ -486,6 +464,7 @@ async function JuryDashboardContent() {
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p> <p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
</div> </div>
</Link> </Link>
{juryCompareEnabled && (
<Link <Link
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md" className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
@@ -498,6 +477,7 @@ async function JuryDashboardContent() {
<p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p> <p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p>
</div> </div>
</Link> </Link>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -670,30 +650,25 @@ function DashboardSkeleton() {
return ( return (
<> <>
{/* Stats skeleton */} {/* Stats skeleton */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="flex items-baseline justify-between border-b border-t py-3 md:hidden">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<Card key={i} className="border-l-4 border-l-muted"> <div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
<CardContent className="flex items-center gap-4 py-5 px-5"> <Skeleton className="h-6 w-8 mx-auto" />
<Skeleton className="h-11 w-11 rounded-xl" /> <Skeleton className="h-3 w-14 mx-auto mt-1" />
<div className="space-y-2"> </div>
<Skeleton className="h-7 w-12" /> ))}
<Skeleton className="h-4 w-24" /> </div>
<div className="hidden md:block">
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-background px-5 py-4">
<Skeleton className="h-9 w-12" />
<Skeleton className="h-4 w-20 mt-1" />
<Skeleton className="h-3 w-16 mt-1" />
</div> </div>
</CardContent>
</Card>
))} ))}
</div> </div>
{/* Progress bar skeleton */}
<Card className="overflow-hidden">
<div className="h-1 w-full bg-muted" />
<CardContent className="py-5 px-6">
<div className="flex items-center justify-between mb-3">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-7 w-16" />
</div> </div>
<Skeleton className="h-3 w-full rounded-full" />
</CardContent>
</Card>
{/* Two-column skeleton */} {/* Two-column skeleton */}
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-6 lg:grid-cols-12">
<div className="lg:col-span-7"> <div className="lg:col-span-7">
@@ -765,6 +740,9 @@ export default async function JuryDashboardPage() {
<Suspense fallback={<DashboardSkeleton />}> <Suspense fallback={<DashboardSkeleton />}>
<JuryDashboardContent /> <JuryDashboardContent />
</Suspense> </Suspense>
{/* Auto-refresh every 30s so voting round changes appear promptly */}
<AutoRefresh intervalMs={30_000} />
</div> </div>
) )
} }

View File

@@ -18,7 +18,12 @@ export default async function JuryLayout({
select: { onboardingCompletedAt: true }, select: { onboardingCompletedAt: true },
}) })
if (!user?.onboardingCompletedAt) { if (!user) {
// User was deleted — session is stale, send to login
redirect('/login')
}
if (!user.onboardingCompletedAt) {
redirect('/onboarding') redirect('/onboarding')
} }

View File

@@ -19,7 +19,12 @@ export default async function MentorLayout({
select: { onboardingCompletedAt: true }, select: { onboardingCompletedAt: true },
}) })
if (!user?.onboardingCompletedAt) { if (!user) {
// User was deleted — session is stale, send to login
redirect('/login')
}
if (!user.onboardingCompletedAt) {
redirect('/onboarding') redirect('/onboarding')
} }
} }

View File

@@ -1,5 +1,6 @@
import { requireRole } from '@/lib/auth-redirect' import { requireRole } from '@/lib/auth-redirect'
import { ObserverNav } from '@/components/layouts/observer-nav' import { ObserverNav } from '@/components/layouts/observer-nav'
import { EditionProvider } from '@/components/observer/observer-edition-context'
export default async function ObserverLayout({ export default async function ObserverLayout({
children, children,
@@ -10,6 +11,7 @@ export default async function ObserverLayout({
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<EditionProvider>
<ObserverNav <ObserverNav
user={{ user={{
name: session.user.name, name: session.user.name,
@@ -17,6 +19,7 @@ export default async function ObserverLayout({
}} }}
/> />
<main className="container-app py-6">{children}</main> <main className="container-app py-6">{children}</main>
</EditionProvider>
</div> </div>
) )
} }

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,15 @@
import type { Metadata } from 'next'
import { ObserverProjectDetail } from '@/components/observer/observer-project-detail'
export const metadata: Metadata = { title: 'Project Detail' }
export const dynamic = 'force-dynamic'
export default async function ObserverProjectDetailPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = await params
return <ObserverProjectDetail projectId={projectId} />
}

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>
)
}

View File

@@ -1,25 +1,9 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -29,702 +13,258 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
FileSpreadsheet,
BarChart3,
Users,
ClipboardList,
CheckCircle2,
TrendingUp,
GitCompare,
UserCheck,
Globe, Globe,
LayoutDashboard,
Filter,
FolderOpen,
TrendingUp,
Users,
BarChart3,
Upload,
Presentation,
Vote,
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils' import type { LucideIcon } from 'lucide-react'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
CrossStageComparisonChart,
JurorConsistencyChart,
DiversityMetricsChart,
} from '@/components/charts'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
// Parse selection value: "all:programId" for edition-wide, or roundId import { GlobalAnalyticsTab } from '@/components/observer/reports/global-analytics-tab'
function parseSelection(value: string | null): { roundId?: string; programId?: string } { import { IntakeReportTabs } from '@/components/observer/reports/intake-report-tabs'
if (!value) return {} import { FilteringReportTabs } from '@/components/observer/reports/filtering-report-tabs'
if (value.startsWith('all:')) return { programId: value.slice(4) } import { EvaluationReportTabs } from '@/components/observer/reports/evaluation-report-tabs'
return { roundId: value } import { SubmissionReportTabs } from '@/components/observer/reports/submission-report-tabs'
import { MentoringReportTabs } from '@/components/observer/reports/mentoring-report-tabs'
import { LiveFinalReportTabs } from '@/components/observer/reports/live-final-report-tabs'
import { DeliberationReportTabs } from '@/components/observer/reports/deliberation-report-tabs'
const ROUND_TYPE_LABELS: Record<string, string> = {
INTAKE: 'Intake',
FILTERING: 'Filtering',
EVALUATION: 'Evaluation',
SUBMISSION: 'Submission',
MENTORING: 'Mentoring',
LIVE_FINAL: 'Live Final',
DELIBERATION: 'Deliberation',
} }
function OverviewTab({ selectedValue }: { selectedValue: string | null }) { type Stage = {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true }) id: string
name: string
const stages = programs?.flatMap(p => status: string
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({ roundType: string
...s, windowCloseAt: Date | null
programName: `${p.year} Edition`, _count: { projects: number; assignments: number; evaluations: number }
})) programId: string
) || [] programName: string
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: overviewStats, isLoading: statsLoading } =
trpc.analytics.getOverviewStats.useQuery(
queryInput,
{ enabled: hasSelection }
)
if (isLoading) {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].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-3 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
const totalProjects = stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0)
const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
const totalPrograms = programs?.length || 0
return (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Stages</p>
<p className="text-2xl font-bold mt-1">{stages.length}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeStages} active
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<BarChart3 className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground mt-1">Across all stages</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Active Stages</p>
<p className="text-2xl font-bold mt-1">{activeStages}</p>
<p className="text-xs text-muted-foreground mt-1">Currently active</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Programs</p>
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
<p className="text-xs text-muted-foreground mt-1">Total programs</p>
</div>
<div className="rounded-xl bg-brand-teal/10 p-3">
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Round/edition-specific overview stats */}
{hasSelection && (
<>
{statsLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].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-3 w-24" />
</CardContent>
</Card>
))}
</div>
) : overviewStats ? (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{queryInput.programId ? 'Edition Overview' : 'Selected Round Details'}</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.projectCount}</div>
<p className="text-xs text-muted-foreground">In this round</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.assignmentCount}</div>
<p className="text-xs text-muted-foreground">
{overviewStats.jurorCount} jurors
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.evaluationCount}</div>
<p className="text-xs text-muted-foreground">Submitted</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.completionRate}%</div>
<Progress value={overviewStats.completionRate} className="mt-2 h-2" gradient />
</CardContent>
</Card>
</div>
</div>
) : null}
</>
)}
{/* Stages Table - Desktop */}
<Card className="hidden md:block">
<CardHeader>
<CardTitle>Stage Reports</CardTitle>
<CardDescription>Progress overview for each stage</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stages.map((stage) => (
<TableRow key={stage.id}>
<TableCell>
<div>
<p className="font-medium">{stage.name}</p>
{stage.windowCloseAt && (
<p className="text-sm text-muted-foreground">
Ends: {formatDateOnly(stage.windowCloseAt)}
</p>
)}
</div>
</TableCell>
<TableCell>{stage.programName}</TableCell>
<TableCell>{stage._count?.projects || '-'}</TableCell>
<TableCell>
<Badge
variant={
stage.status === 'STAGE_ACTIVE'
? 'default'
: stage.status === 'STAGE_CLOSED'
? 'secondary'
: 'outline'
}
>
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Stages Cards - Mobile */}
<div className="space-y-4 md:hidden">
<h2 className="text-lg font-semibold">Stage Reports</h2>
{stages.map((stage) => (
<Card key={stage.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<p className="font-medium">{stage.name}</p>
<Badge
variant={
stage.status === 'STAGE_ACTIVE'
? 'default'
: stage.status === 'STAGE_CLOSED'
? 'secondary'
: 'outline'
}
>
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{stage.programName}</p>
{stage.windowCloseAt && (
<p className="text-xs text-muted-foreground">
Ends: {formatDateOnly(stage.windowCloseAt)}
</p>
)}
<div className="text-sm">
<span>{stage._count?.projects || 0} projects</span>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
} }
function AnalyticsTab({ selectedValue }: { selectedValue: string }) { type TabDef = { value: string; label: string; icon: LucideIcon }
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } = function getRoundTabs(roundType: string): TabDef[] {
trpc.analytics.getScoreDistribution.useQuery( switch (roundType) {
queryInput, case 'INTAKE':
{ enabled: hasSelection } return [{ value: 'overview', label: 'Overview', icon: LayoutDashboard }]
) case 'FILTERING':
return [
const { data: timeline, isLoading: timelineLoading } = { value: 'screening', label: 'Screening', icon: Filter },
trpc.analytics.getEvaluationTimeline.useQuery( ]
queryInput, case 'EVALUATION':
{ enabled: hasSelection } return [
) { value: 'evaluation', label: 'Evaluation', icon: TrendingUp },
]
const { data: statusBreakdown, isLoading: statusLoading } = case 'SUBMISSION':
trpc.analytics.getStatusBreakdown.useQuery( return [{ value: 'overview', label: 'Overview', icon: Upload }]
queryInput, case 'MENTORING':
{ enabled: hasSelection } return [{ value: 'overview', label: 'Overview', icon: Users }]
) case 'LIVE_FINAL':
return [{ value: 'session', label: 'Session', icon: Presentation }]
const { data: jurorWorkload, isLoading: workloadLoading } = case 'DELIBERATION':
trpc.analytics.getJurorWorkload.useQuery( return [
queryInput, { value: 'deliberation', label: 'Deliberation', icon: Vote },
{ enabled: hasSelection } ]
) default:
return []
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ ...queryInput, limit: 15 },
{ enabled: hasSelection }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
queryInput,
{ enabled: hasSelection }
)
return (
<div className="space-y-6">
{/* Row 1: Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2">
{scoreLoading ? (
<Skeleton className="h-[350px]" />
) : scoreDistribution ? (
<ScoreDistributionChart
data={scoreDistribution.distribution}
averageScore={scoreDistribution.averageScore}
totalScores={scoreDistribution.totalScores}
/>
) : null}
{statusLoading ? (
<Skeleton className="h-[350px]" />
) : statusBreakdown ? (
<StatusBreakdownChart data={statusBreakdown} />
) : null}
</div>
{/* Row 2: Evaluation Timeline */}
{timelineLoading ? (
<Skeleton className="h-[350px]" />
) : timeline?.length ? (
<EvaluationTimelineChart data={timeline} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No evaluation data available yet
</p>
</CardContent>
</Card>
)}
{/* Row 3: Criteria Scores */}
{criteriaLoading ? (
<Skeleton className="h-[350px]" />
) : criteriaScores?.length ? (
<CriteriaScoresChart data={criteriaScores} />
) : null}
{/* Row 4: Juror Workload */}
{workloadLoading ? (
<Skeleton className="h-[450px]" />
) : jurorWorkload?.length ? (
<JurorWorkloadChart data={jurorWorkload} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No juror assignments yet
</p>
</CardContent>
</Card>
)}
{/* Row 5: Project Rankings */}
{rankingsLoading ? (
<Skeleton className="h-[550px]" />
) : projectRankings?.length ? (
<ProjectRankingsChart data={projectRankings} limit={15} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No project scores available yet
</p>
</CardContent>
</Card>
)}
</div>
)
}
function CrossStageTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap(p =>
((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
)
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
)
} }
if (programsLoading) return <Skeleton className="h-[400px]" />
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Select Stages to Compare</CardTitle>
<CardDescription>Choose at least 2 stages</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{stages.map((stage) => (
<Badge
key={stage.id}
variant={selectedRoundIds.includes(stage.id) ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(stage.id)}
>
{stage.programName} - {stage.name}
</Badge>
))}
</div>
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 stages to enable comparison
</p>
)}
</CardContent>
</Card>
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
{comparison && (
<CrossStageComparisonChart data={comparison as Array<{
roundId: string; roundName: string; projectCount: number; evaluationCount: number
completionRate: number; averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}>} />
)}
</div>
)
} }
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) { function RoundTypeContent({
const queryInput = parseSelection(selectedValue) roundType,
const hasSelection = !!queryInput.roundId || !!queryInput.programId roundId,
programId,
const { data: consistency, isLoading } = stages,
trpc.analytics.getJurorConsistency.useQuery( selectedValue,
queryInput, }: {
{ enabled: hasSelection } roundType: string
) roundId: string
programId: string
if (isLoading) return <Skeleton className="h-[400px]" /> stages: Stage[]
selectedValue: string | null
if (!consistency) return null }) {
switch (roundType) {
case 'INTAKE':
return <IntakeReportTabs roundId={roundId} programId={programId} />
case 'FILTERING':
return <FilteringReportTabs roundId={roundId} programId={programId} />
case 'EVALUATION':
return ( return (
<JurorConsistencyChart <EvaluationReportTabs
data={consistency as { roundId={roundId}
overallAverage: number programId={programId}
jurors: Array<{ stages={stages}
userId: string; name: string; email: string selectedValue={selectedValue}
evaluationCount: number; averageScore: number
stddev: number; deviationFromOverall: number; isOutlier: boolean
}>
}}
/> />
) )
case 'SUBMISSION':
return <SubmissionReportTabs roundId={roundId} programId={programId} />
case 'MENTORING':
return <MentoringReportTabs roundId={roundId} programId={programId} />
case 'LIVE_FINAL':
return <LiveFinalReportTabs roundId={roundId} programId={programId} />
case 'DELIBERATION':
return <DeliberationReportTabs roundId={roundId} programId={programId} />
default:
return null
}
} }
function DiversityTab({ selectedValue }: { selectedValue: string }) { function ReportsPageContent() {
const queryInput = parseSelection(selectedValue) const searchParams = useSearchParams()
const hasSelection = !!queryInput.roundId || !!queryInput.programId const roundFromUrl = searchParams.get('round')
const [selectedValue, setSelectedValue] = useState<string | null>(roundFromUrl)
const { data: diversity, isLoading } = const [activeTab, setActiveTab] = useState<string | null>(null)
trpc.analytics.getDiversityMetrics.useQuery(
queryInput,
{ enabled: hasSelection }
)
if (isLoading) return <Skeleton className="h-[400px]" />
if (!diversity) return null
return (
<DiversityMetricsChart
data={diversity as {
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 }[]
}}
/>
)
}
export default function ObserverReportsPage() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true }) const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap(p => const stages: Stage[] = programs?.flatMap(p =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({ ((p.stages || []) as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number; evaluations: number } }[]).map(s => ({
...s, ...s,
programId: p.id, programId: p.id,
programName: `${p.year} Edition`, programName: `${p.year} Edition`,
})) }))
) || [] ) ?? []
// Set default selected stage const allRoundIds = stages.map((s) => s.id)
useEffect(() => {
if (stages.length && !selectedValue) { if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id) const active = stages.find((s) => s.status === 'ROUND_ACTIVE')
setSelectedValue(active ? active.id : stages[0].id)
} }
}, [stages.length, selectedValue])
const hasSelection = !!selectedValue // Reset to first round-specific tab when round selection changes
useEffect(() => {
setActiveTab(null)
}, [selectedValue])
const isAllRounds = selectedValue?.startsWith('all:')
const selectedRound = stages.find((s) => s.id === selectedValue) const selectedRound = stages.find((s) => s.id === selectedValue)
const roundType = selectedRound?.roundType ?? ''
const programId = isAllRounds
? selectedValue!.slice(4)
: selectedRound?.programId ?? programs?.[0]?.id ?? ''
const roundSpecificTabs = isAllRounds
? [{ value: 'progress', label: 'Progress', icon: TrendingUp }]
: getRoundTabs(roundType)
const allTabs: TabDef[] = [
...roundSpecificTabs,
{ value: 'global', label: 'Global', icon: Globe },
]
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */}
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1> <h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">View evaluation progress and statistics</p>
View evaluation progress and statistics
</p>
</div> </div>
{/* Stage Selector */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Select Stage:</label> <label className="text-sm font-medium">Select Round:</label>
{stagesLoading ? ( {stagesLoading ? (
<Skeleton className="h-10 w-full sm:w-[300px]" /> <Skeleton className="h-10 w-full sm:w-[300px]" />
) : stages.length > 0 ? ( ) : stages.length > 0 ? (
<Select value={selectedValue || ''} onValueChange={setSelectedValue}> <Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-full sm:w-[300px]"> <SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a stage" /> <SelectValue placeholder="Select a round" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{programs?.map((p) => ( {programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}> <SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Stages {p.year} Edition All Rounds
</SelectItem> </SelectItem>
))} ))}
{stages.map((stage) => ( {stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}> <SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name} {stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
<p className="text-sm text-muted-foreground">No stages available</p> <p className="text-sm text-muted-foreground">No rounds available</p>
)} )}
</div> </div>
{/* Tabs */} {selectedValue && (
<Tabs defaultValue="overview" className="space-y-6"> <Tabs value={activeTab ?? allTabs[0]?.value ?? 'global'} onValueChange={setActiveTab} className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<TabsList> <TabsList>
<TabsTrigger value="overview" className="gap-2"> {allTabs.map((tab) => (
<FileSpreadsheet className="h-4 w-4" /> <TabsTrigger key={tab.value} value={tab.value} className="gap-2">
Overview <tab.icon className="h-4 w-4" />
</TabsTrigger> {tab.label}
<TabsTrigger value="analytics" className="gap-2" disabled={!hasSelection}>
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="cross-stage" className="gap-2">
<GitCompare className="h-4 w-4" />
Cross-Round
</TabsTrigger>
<TabsTrigger value="consistency" className="gap-2" disabled={!hasSelection}>
<UserCheck className="h-4 w-4" />
Juror Consistency
</TabsTrigger>
<TabsTrigger value="diversity" className="gap-2" disabled={!hasSelection}>
<Globe className="h-4 w-4" />
Diversity
</TabsTrigger> </TabsTrigger>
))}
</TabsList> </TabsList>
{selectedValue && !selectedValue.startsWith('all:') && (
<ExportPdfButton <TabsContent value="global">
roundId={selectedValue} <GlobalAnalyticsTab
roundName={selectedRound?.name} programId={programId}
programName={selectedRound?.programName} roundIds={allRoundIds.length >= 2 ? allRoundIds : undefined}
/> />
)}
</div>
<TabsContent value="overview">
<OverviewTab selectedValue={selectedValue} />
</TabsContent> </TabsContent>
<TabsContent value="analytics"> {/* Round-type-specific or "All Rounds" progress tab */}
{hasSelection ? ( {roundSpecificTabs.map((tab) => (
<AnalyticsTab selectedValue={selectedValue!} /> <TabsContent key={tab.value} value={tab.value}>
) : ( {isAllRounds ? (
<Card> <EvaluationReportTabs
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> roundId=""
<BarChart3 className="h-12 w-12 text-muted-foreground/50" /> programId={programId}
<p className="mt-2 font-medium">Select a round</p> stages={stages}
<p className="text-sm text-muted-foreground"> selectedValue={selectedValue}
Choose a round or edition from the dropdown above to view analytics />
</p> ) : selectedRound ? (
</CardContent> <RoundTypeContent
</Card> roundType={roundType}
)} roundId={selectedRound.id}
</TabsContent> programId={programId}
stages={stages}
<TabsContent value="cross-stage"> selectedValue={selectedValue}
<CrossStageTab /> />
</TabsContent> ) : null}
<TabsContent value="consistency">
{hasSelection ? (
<JurorConsistencyTab selectedValue={selectedValue!} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<UserCheck className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round or edition above to view juror consistency metrics
</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="diversity">
{hasSelection ? (
<DiversityTab selectedValue={selectedValue!} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Globe className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round or edition above to view diversity metrics
</p>
</CardContent>
</Card>
)}
</TabsContent> </TabsContent>
))}
</Tabs> </Tabs>
)}
</div> </div>
) )
} }
export default function ObserverReportsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<div>
<Skeleton className="h-8 w-32" />
<Skeleton className="mt-2 h-4 w-56" />
</div>
<Skeleton className="h-10 w-full sm:w-[300px]" />
<Skeleton className="h-[400px] w-full" />
</div>
}
>
<ReportsPageContent />
</Suspense>
)
}

View File

@@ -13,8 +13,10 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
const now = new Date() const now = new Date()
// Delete projects where isDraft=true AND draftExpiresAt has passed // Delete projects where isDraft=true AND draftExpiresAt has passed
// Exclude test projects — they are managed separately
const result = await prisma.project.deleteMany({ const result = await prisma.project.deleteMany({
where: { where: {
isTest: false,
isDraft: true, isDraft: true,
draftExpiresAt: { draftExpiresAt: {
lt: now, lt: now,

View File

@@ -43,6 +43,44 @@
/* Source the JS config for extended theme values */ /* Source the JS config for extended theme values */
@config "../../tailwind.config.ts"; @config "../../tailwind.config.ts";
/* Tremor generates Tailwind utility classes dynamically via template literals
(e.g. `fill-${color}-${shade}`). Tailwind v4's scanner cannot detect these,
so we must explicitly safelist every color+shade+property combination. */
@source "../../node_modules/@tremor/react/dist/**/*.js";
/* Safelist Tremor chart color utilities — all colors × key shades × fill/stroke/bg */
@source inline("{fill,stroke,bg,text}-{blue,emerald,amber,violet,rose,indigo,sky,fuchsia,lime,orange,cyan,teal,purple,slate,gray,zinc,neutral,stone,red,yellow,green,pink}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("hover:{bg,text,border}-{blue,emerald,amber,violet,rose,indigo,sky,fuchsia,lime,orange,cyan,teal,purple,slate,gray,zinc,neutral,stone,red,yellow,green,pink}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("{border,ring}-{blue,emerald,amber,violet,rose,indigo,sky,fuchsia,lime,orange,cyan,teal,purple,slate,gray,zinc,neutral,stone,red,yellow,green,pink}-{50,100,200,300,400,500,600,700,800,900,950}");
/* Safelist Tremor design token utility classes */
@source inline("{fill,stroke,bg,text,border}-tremor-{brand,background,border,content,content-emphasis,default,label,card,dropdown}");
/* Tremor design tokens — normally registered by Tremor's TW3 plugin.
We define them manually for Tailwind v4 compatibility. */
@theme {
--color-tremor-brand: var(--color-blue-500);
--color-tremor-brand-emphasis: var(--color-blue-700);
--color-tremor-brand-inverted: #fff;
--color-tremor-brand-muted: var(--color-blue-200);
--color-tremor-brand-faint: var(--color-blue-50);
--color-tremor-background: #fff;
--color-tremor-background-emphasis: var(--color-gray-700);
--color-tremor-background-muted: var(--color-gray-50);
--color-tremor-background-subtle: var(--color-gray-100);
--color-tremor-border: var(--color-gray-200);
--color-tremor-content: var(--color-gray-500);
--color-tremor-content-emphasis: var(--color-gray-700);
--color-tremor-content-strong: var(--color-gray-900);
--color-tremor-content-subtle: var(--color-gray-400);
--color-tremor-content-inverted: #fff;
--color-tremor-ring: var(--color-gray-200);
--color-tremor-default: var(--color-gray-500);
--color-tremor-label: var(--color-gray-400);
--color-tremor-card: #fff;
--color-tremor-dropdown: #fff;
}
/* Theme variables - using CSS custom properties with Tailwind v4 @theme */ /* Theme variables - using CSS custom properties with Tailwind v4 @theme */
@theme { @theme {
/* Container */ /* Container */
@@ -294,3 +332,46 @@
background: hsl(var(--muted-foreground) / 0.5); background: hsl(var(--muted-foreground) / 0.5);
} }
} }
/* Tremor chart tooltip fix — ensure solid background */
[class*="tremor-"] [role="tooltip"],
.recharts-tooltip-wrapper .recharts-default-tooltip,
div[class*="tremor"][class*="tooltip"],
div[class*="recharts-tooltip"] {
background-color: hsl(var(--card)) !important;
border: 1px solid hsl(var(--border)) !important;
border-radius: 0.5rem !important;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
opacity: 1 !important;
}
.dark div[class*="tremor"][class*="tooltip"],
.dark .recharts-tooltip-wrapper .recharts-default-tooltip,
.dark div[class*="recharts-tooltip"] {
background-color: hsl(var(--card)) !important;
border-color: hsl(var(--border)) !important;
}
/* Tremor/Recharts tooltip color indicator icons — fix rendering */
.recharts-tooltip-wrapper svg.recharts-surface {
display: inline-block !important;
overflow: visible !important;
}
/* Tremor custom tooltip color dots */
[class*="tremor"] [role="tooltip"] span[class*="bg-"],
[class*="tremor"] [role="tooltip"] span[style*="background"] {
border-radius: 2px !important;
min-width: 10px !important;
min-height: 10px !important;
flex-shrink: 0 !important;
}
/* Recharts default tooltip icon fix — ensure SVG paths have correct fill */
.recharts-default-tooltip .recharts-tooltip-item-list {
padding: 0 !important;
}
.recharts-default-tooltip .recharts-tooltip-item svg {
border: none !important;
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css' import './globals.css'
import { Providers } from './providers' import { Providers } from './providers'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -22,7 +23,10 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers> <Providers>
<ImpersonationBanner />
{children}
</Providers>
<Toaster <Toaster
position="top-right" position="top-right"
toastOptions={{ toastOptions={{

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@ export function AdminOverrideDialog({
const { data: session } = trpc.deliberation.getSession.useQuery( const { data: session } = trpc.deliberation.getSession.useQuery(
{ sessionId }, { sessionId },
{ enabled: open } { enabled: open, refetchInterval: 10_000 }
); );
const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({ const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({
@@ -91,7 +91,7 @@ export function AdminOverrideDialog({
<Label>Project Rankings</Label> <Label>Project Rankings</Label>
<div className="space-y-2"> <div className="space-y-2">
{projectIds.map((projectId) => { {projectIds.map((projectId) => {
const project = session?.projects?.find((p: any) => p.id === projectId); const project = session?.results?.find((r) => r.project.id === projectId)?.project;
return ( return (
<div key={projectId} className="flex items-center gap-3"> <div key={projectId} className="flex items-center gap-3">
<Input <Input

View File

@@ -18,8 +18,17 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false); const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
const { data: session } = trpc.deliberation.getSession.useQuery({ sessionId }); const { data: session } = trpc.deliberation.getSession.useQuery(
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery({ sessionId }); { sessionId },
{ refetchInterval: 10_000 }
);
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery(
{ sessionId },
{
refetchInterval: 10_000,
enabled: session?.status === 'TALLYING' || session?.status === 'RUNOFF' || session?.status === 'DELIB_LOCKED',
}
);
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({ const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -46,35 +55,33 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
return ( return (
<Card> <Card>
<CardContent className="p-12 text-center"> <CardContent className="p-12 text-center">
<p className="text-muted-foreground">No voting results yet</p> <p className="text-muted-foreground">
{session?.status === 'DELIB_OPEN' || session?.status === 'VOTING'
? 'Voting has not been tallied yet'
: 'No voting results yet'}
</p>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
// Detect ties: check if two or more top-ranked candidates share the same totalScore // Detect ties using the backend-computed flag, with client-side fallback
const hasTie = (() => { const hasTie = aggregatedResults.hasTies ?? (() => {
const rankings = aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }> | undefined; const rankings = aggregatedResults.rankings as Array<{ score?: number; projectId: string }> | undefined;
if (!rankings || rankings.length < 2) return false; if (!rankings || rankings.length < 2) return false;
// Group projects by totalScore
const scoreGroups = new Map<number, string[]>(); const scoreGroups = new Map<number, string[]>();
for (const r of rankings) { for (const r of rankings) {
const score = r.totalScore ?? 0; const score = r.score ?? 0;
const group = scoreGroups.get(score) || []; const group = scoreGroups.get(score) || [];
group.push(r.projectId); group.push(r.projectId);
scoreGroups.set(score, group); scoreGroups.set(score, group);
} }
// A tie exists if the highest score is shared by 2+ projects
const topScore = Math.max(...scoreGroups.keys()); const topScore = Math.max(...scoreGroups.keys());
const topGroup = scoreGroups.get(topScore); const topGroup = scoreGroups.get(topScore);
return (topGroup?.length ?? 0) >= 2; return (topGroup?.length ?? 0) >= 2;
})(); })();
const tiedProjectIds = hasTie const tiedProjectIds = aggregatedResults.tiedProjectIds ?? [];
? (aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }>) const canFinalize = session?.status === 'TALLYING' && !hasTie;
.filter((r) => r.totalScore === (aggregatedResults.rankings as Array<{ totalScore?: number }>)[0]?.totalScore)
.map((r) => r.projectId)
: [];
const canFinalize = session?.status === 'DELIB_TALLYING' && !hasTie;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -95,17 +102,17 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
#{index + 1} #{result.rank ?? index + 1}
</div> </div>
<div> <div>
<p className="font-medium">{result.projectTitle}</p> <p className="font-medium">{result.projectTitle ?? result.projectId}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{result.votes} votes {result.averageRank?.toFixed(2)} avg rank {result.voteCount} votes
</p> </p>
</div> </div>
</div> </div>
<Badge variant="outline" className="text-lg"> <Badge variant="outline" className="text-lg">
{result.totalScore?.toFixed(1) || 0} {result.score?.toFixed?.(1) ?? 0}
</Badge> </Badge>
</div> </div>
))} ))}

View File

@@ -1,6 +1,5 @@
'use client' 'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -42,10 +41,21 @@ interface EvaluationSummaryCardProps {
roundId: string roundId: string
} }
interface BooleanStats {
yesCount: number
noCount: number
total: number
yesPercent: number
trueLabel: string
falseLabel: string
}
interface ScoringPatterns { interface ScoringPatterns {
averageGlobalScore: number | null averageGlobalScore: number | null
consensus: number consensus: number
criterionAverages: Record<string, number> criterionAverages: Record<string, number>
booleanCriteria?: Record<string, BooleanStats>
textResponses?: Record<string, string[]>
evaluatorCount: number evaluatorCount: number
} }
@@ -74,8 +84,6 @@ export function EvaluationSummaryCard({
projectId, projectId,
roundId, roundId,
}: EvaluationSummaryCardProps) { }: EvaluationSummaryCardProps) {
const [isGenerating, setIsGenerating] = useState(false)
const { const {
data: summary, data: summary,
isLoading, isLoading,
@@ -86,19 +94,18 @@ export function EvaluationSummaryCard({
onSuccess: () => { onSuccess: () => {
toast.success('AI summary generated successfully') toast.success('AI summary generated successfully')
refetch() refetch()
setIsGenerating(false)
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || 'Failed to generate summary') toast.error(error.message || 'Failed to generate summary')
setIsGenerating(false)
}, },
}) })
const handleGenerate = () => { const handleGenerate = () => {
setIsGenerating(true)
generateMutation.mutate({ projectId, roundId }) generateMutation.mutate({ projectId, roundId })
} }
const isGenerating = generateMutation.isPending
if (isLoading) { if (isLoading) {
return ( return (
<Card> <Card>
@@ -296,10 +303,10 @@ export function EvaluationSummaryCard({
</div> </div>
)} )}
{/* Criterion Averages */} {/* Criterion Averages (Numeric) */}
{Object.keys(patterns.criterionAverages).length > 0 && ( {Object.keys(patterns.criterionAverages).length > 0 && (
<div> <div>
<p className="text-sm font-medium mb-2">Criterion Averages</p> <p className="text-sm font-medium mb-2">Score Averages</p>
<div className="space-y-2"> <div className="space-y-2">
{Object.entries(patterns.criterionAverages).map(([label, avg]) => ( {Object.entries(patterns.criterionAverages).map(([label, avg]) => (
<div key={label} className="flex items-center gap-3"> <div key={label} className="flex items-center gap-3">
@@ -323,6 +330,69 @@ export function EvaluationSummaryCard({
</div> </div>
)} )}
{/* Boolean Criteria (Yes/No) */}
{patterns.booleanCriteria && Object.keys(patterns.booleanCriteria).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Yes/No Decisions</p>
<div className="space-y-3">
{Object.entries(patterns.booleanCriteria).map(([label, stats]) => (
<div key={label} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground truncate">
{label}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{stats.yesCount} {stats.trueLabel} / {stats.noCount} {stats.falseLabel}
</span>
</div>
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
{stats.yesCount > 0 && (
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${stats.yesPercent}%` }}
/>
)}
{stats.noCount > 0 && (
<div
className="h-full bg-red-400 transition-all"
style={{ width: `${100 - stats.yesPercent}%` }}
/>
)}
</div>
<div className="flex justify-between text-xs">
<span className="text-emerald-600">{stats.yesPercent}% {stats.trueLabel}</span>
<span className="text-red-500">{100 - stats.yesPercent}% {stats.falseLabel}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Text Responses */}
{patterns.textResponses && Object.keys(patterns.textResponses).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Text Responses</p>
<div className="space-y-3">
{Object.entries(patterns.textResponses).map(([label, responses]) => (
<div key={label} className="space-y-1.5">
<p className="text-sm text-muted-foreground">{label}</p>
<div className="space-y-1.5">
{responses.map((text, i) => (
<div
key={i}
className="text-sm p-2 rounded border bg-muted/50 whitespace-pre-wrap"
>
{text}
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Recommendation */} {/* Recommendation */}
{summaryData.recommendation && ( {summaryData.recommendation && (
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200"> <div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">

View File

@@ -1,10 +1,11 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useRef, useEffect } from 'react'
import { Trash2, UserPlus } from 'lucide-react' import { Trash2, UserPlus, Pencil } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { import {
Table, Table,
TableBody, TableBody,
@@ -45,6 +46,79 @@ interface JuryMembersTableProps {
members: JuryMember[] members: JuryMember[]
} }
function InlineCapEdit({
memberId,
currentValue,
juryGroupId,
}: {
memberId: string
currentValue: number | null | undefined
juryGroupId: string
}) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(currentValue?.toString() ?? '')
const inputRef = useRef<HTMLInputElement>(null)
const utils = trpc.useUtils()
const { mutate: updateMember, isPending } = trpc.juryGroup.updateMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Cap updated')
setEditing(false)
},
onError: (err) => toast.error(err.message),
})
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
const save = () => {
const trimmed = value.trim()
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
toast.error('Enter a positive number or leave empty for no cap')
return
}
if (newVal === (currentValue ?? null)) {
setEditing(false)
return
}
updateMember({ id: memberId, maxAssignmentsOverride: newVal })
}
if (editing) {
return (
<Input
ref={inputRef}
type="number"
min={1}
className="h-7 w-20 text-xs"
value={value}
placeholder="∞"
disabled={isPending}
onChange={(e) => setValue(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === 'Enter') save()
if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
}}
/>
)
}
return (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-sm hover:bg-muted transition-colors group"
onClick={() => { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
>
<span>{currentValue ?? '∞'}</span>
<Pencil className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
)
}
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) { export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
const [addDialogOpen, setAddDialogOpen] = useState(false) const [addDialogOpen, setAddDialogOpen] = useState(false)
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null) const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
@@ -80,6 +154,7 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Role</TableHead>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead> <TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
<TableHead className="hidden lg:table-cell">Cap Mode</TableHead> <TableHead className="hidden lg:table-cell">Cap Mode</TableHead>
@@ -89,7 +164,7 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableBody> <TableBody>
{members.length === 0 ? ( {members.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground"> <TableCell colSpan={6} className="text-center text-muted-foreground">
No members yet. Add members to get started. No members yet. Add members to get started.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -99,11 +174,20 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableCell className="font-medium"> <TableCell className="font-medium">
{member.user.name || 'Unnamed User'} {member.user.name || 'Unnamed User'}
</TableCell> </TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="outline" className="text-[10px] capitalize">
{member.role?.toLowerCase().replace('_', ' ') || 'member'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{member.user.email} {member.user.email}
</TableCell> </TableCell>
<TableCell className="hidden sm:table-cell"> <TableCell className="hidden sm:table-cell">
{member.maxAssignmentsOverride ?? '—'} <InlineCapEdit
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
juryGroupId={juryGroupId}
/>
</TableCell> </TableCell>
<TableCell className="hidden lg:table-cell"> <TableCell className="hidden lg:table-cell">
{member.capModeOverride ? ( {member.capModeOverride ? (

View File

@@ -5,7 +5,7 @@ import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ChevronLeft, ChevronRight, Play, Square, Timer } from 'lucide-react'; import { ChevronLeft, ChevronRight, Play, Square, Pause, Timer } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface LiveControlPanelProps { interface LiveControlPanelProps {
@@ -15,18 +15,36 @@ interface LiveControlPanelProps {
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) { export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const [timerSeconds, setTimerSeconds] = useState(300); // 5 minutes default const [timerSeconds, setTimerSeconds] = useState(300);
const [isTimerRunning, setIsTimerRunning] = useState(false); const [isTimerRunning, setIsTimerRunning] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }); const { data: cursor } = trpc.live.getCursor.useQuery(
// TODO: Add getScores to live router { roundId },
const scores: any[] = []; { refetchInterval: 5000 }
);
// TODO: Implement cursor mutation const jumpMutation = trpc.live.jump.useMutation({
const moveCursorMutation = { onSuccess: () => {
mutate: () => {}, utils.live.getCursor.invalidate({ roundId });
isPending: false },
}; onError: (err) => toast.error(err.message),
});
const pauseMutation = trpc.live.pause.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session paused');
},
onError: (err) => toast.error(err.message),
});
const resumeMutation = trpc.live.resume.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session resumed');
},
onError: (err) => toast.error(err.message),
});
useEffect(() => { useEffect(() => {
if (!isTimerRunning) return; if (!isTimerRunning) return;
@@ -44,14 +62,24 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isTimerRunning]); }, [isTimerRunning]);
const currentIndex = cursor?.activeOrderIndex ?? 0;
const totalProjects = cursor?.totalProjects ?? 0;
const isNavigating = jumpMutation.isPending;
const handlePrevious = () => { const handlePrevious = () => {
// TODO: Implement previous navigation if (currentIndex <= 0) {
toast.info('Previous navigation not yet implemented'); toast.info('Already at the first project');
return;
}
jumpMutation.mutate({ roundId, index: currentIndex - 1 });
}; };
const handleNext = () => { const handleNext = () => {
// TODO: Implement next navigation if (currentIndex >= totalProjects - 1) {
toast.info('Next navigation not yet implemented'); toast.info('Already at the last project');
return;
}
jumpMutation.mutate({ roundId, index: currentIndex + 1 });
}; };
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@@ -67,12 +95,17 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle>Current Project</CardTitle> <CardTitle>Current Project</CardTitle>
<div className="flex gap-2"> <div className="flex items-center gap-2">
{cursor && (
<span className="text-sm text-muted-foreground tabular-nums">
{currentIndex + 1} / {totalProjects}
</span>
)}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handlePrevious} onClick={handlePrevious}
disabled={moveCursorMutation.isPending} disabled={isNavigating || currentIndex <= 0}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@@ -80,7 +113,7 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleNext} onClick={handleNext}
disabled={moveCursorMutation.isPending} disabled={isNavigating || currentIndex >= totalProjects - 1}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
@@ -92,13 +125,24 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3> <h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
{cursor.activeProject.teamName && (
<p className="text-muted-foreground">{cursor.activeProject.teamName}</p>
)}
</div> </div>
<div className="text-sm text-muted-foreground"> {cursor.activeProject.tags && (cursor.activeProject.tags as string[]).length > 0 && (
Total projects: {cursor.totalProjects} <div className="flex flex-wrap gap-1">
{(cursor.activeProject.tags as string[]).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div> </div>
)}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No project selected</p> <p className="text-muted-foreground">
{cursor ? 'No project selected' : 'No live session active for this round'}
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -144,48 +188,48 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
</CardContent> </CardContent>
</Card> </Card>
{/* Voting Controls */} {/* Session Controls */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Voting Controls</CardTitle> <CardTitle>Session Controls</CardTitle>
<CardDescription>Manage jury and audience voting</CardDescription> <CardDescription>Pause or resume the live presentation</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Button className="w-full" variant="default"> {cursor?.isPaused ? (
<Play className="mr-2 h-4 w-4" /> <Button
Open Jury Voting className="w-full"
</Button> onClick={() => resumeMutation.mutate({ roundId })}
<Button className="w-full" variant="outline"> disabled={resumeMutation.isPending}
Close Voting
</Button>
</CardContent>
</Card>
{/* Scores Display */}
<Card>
<CardHeader>
<CardTitle>Live Scores</CardTitle>
</CardHeader>
<CardContent>
{scores && scores.length > 0 ? (
<div className="space-y-2">
{scores.map((score: any, index: number) => (
<div
key={score.projectId}
className="flex items-center justify-between rounded-lg border p-3"
> >
<div> <Play className="mr-2 h-4 w-4" />
<p className="font-medium"> {resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
#{index + 1} {score.projectTitle} </Button>
</p> ) : (
<p className="text-sm text-muted-foreground">{score.votes} votes</p> <Button
</div> className="w-full"
<Badge variant="outline">{score.totalScore.toFixed(1)}</Badge> variant="outline"
onClick={() => pauseMutation.mutate({ roundId })}
disabled={pauseMutation.isPending || !cursor}
>
<Pause className="mr-2 h-4 w-4" />
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
</Button>
)}
{cursor?.isPaused && (
<Badge variant="destructive" className="w-full justify-center py-1">
Session Paused
</Badge>
)}
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
{cursor.openCohorts.map((cohort: any) => (
<div key={cohort.id} className="flex items-center justify-between text-sm">
<span>{cohort.name}</span>
<Badge variant="outline">{cohort.votingMode}</Badge>
</div> </div>
))} ))}
</div> </div>
) : (
<p className="text-center text-muted-foreground">No scores yet</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -53,6 +53,9 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
NONE: 'Not Invited', NONE: 'Not Invited',
INVITED: 'Invited',
ACTIVE: 'Active',
SUSPENDED: 'Suspended',
} }
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = { const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {

View File

@@ -1,166 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { FileDown, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import {
createReportDocument,
addCoverPage,
addPageBreak,
addHeader,
addSectionTitle,
addStatCards,
addTable,
addAllPageFooters,
savePdf,
} from '@/lib/pdf-generator'
interface PdfReportProps {
roundId: string
sections: string[]
}
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ roundId, sections },
{ enabled: false }
)
const handleGenerate = useCallback(async () => {
setGenerating(true)
toast.info('Generating PDF report...')
try {
const result = await refetch()
if (!result.data) {
toast.error('Failed to fetch report data')
return
}
const data = result.data as Record<string, unknown>
const rName = String(data.roundName || 'Report')
const pName = String(data.programName || '')
// 1. Create document
const doc = await createReportDocument()
// 2. Cover page
await addCoverPage(doc, {
title: 'Round Report',
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
roundName: rName,
programName: pName,
})
// 3. Summary
const summary = data.summary as Record<string, unknown> | undefined
if (summary) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Summary', 28)
y = addStatCards(doc, [
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
{
label: 'Avg Score',
value: summary.averageScore != null
? Number(summary.averageScore).toFixed(1)
: '--',
},
{
label: 'Completion',
value: summary.completionRate != null
? `${Number(summary.completionRate).toFixed(0)}%`
: '--',
},
], y)
}
// 4. Rankings
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
if (rankings && rankings.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Project Rankings', 28)
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
const rows = rankings.map((r, i) => [
i + 1,
String(r.title ?? ''),
String(r.teamName ?? ''),
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
String(r.evaluationCount ?? 0),
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
])
y = addTable(doc, headers, rows, y)
}
// 5. Juror stats
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
if (jurorStats && jurorStats.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Juror Statistics', 28)
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
const rows = jurorStats.map((j) => [
String(j.name ?? ''),
String(j.assigned ?? 0),
String(j.completed ?? 0),
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
])
y = addTable(doc, headers, rows, y)
}
// 6. Criteria breakdown
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
const headers = ['Criterion', 'Avg Score', 'Responses']
const rows = criteriaBreakdown.map((c) => [
String(c.label ?? ''),
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
String(c.count ?? 0),
])
y = addTable(doc, headers, rows, y)
}
// 7. Footers
addAllPageFooters(doc)
// 8. Save
const dateStr = new Date().toISOString().split('T')[0]
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
toast.success('PDF report downloaded successfully')
} catch (err) {
console.error('PDF generation error:', err)
toast.error('Failed to generate PDF report')
} finally {
setGenerating(false)
}
}, [refetch])
return (
<Button variant="outline" onClick={handleGenerate} disabled={generating}>
{generating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
{generating ? 'Generating...' : 'Export PDF Report'}
</Button>
)
}

View File

@@ -31,15 +31,21 @@ export function ResultLockControls({ competitionId, roundId, category }: ResultL
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false); const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
const [unlockReason, setUnlockReason] = useState(''); const [unlockReason, setUnlockReason] = useState('');
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery({ const { data: lockStatus } = trpc.resultLock.isLocked.useQuery(
competitionId, { competitionId, roundId, category },
roundId, { refetchInterval: 15_000 }
category );
});
const { data: history } = trpc.resultLock.history.useQuery({ const { data: history } = trpc.resultLock.history.useQuery(
competitionId { competitionId },
}); { refetchInterval: 15_000 }
);
// Fetch project rankings for the snapshot
const { data: projectRankings } = trpc.analytics.getProjectRankings.useQuery(
{ roundId, limit: 5000 },
{ enabled: !!roundId }
);
const lockMutation = trpc.resultLock.lock.useMutation({ const lockMutation = trpc.resultLock.lock.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -67,11 +73,25 @@ export function ResultLockControls({ competitionId, roundId, category }: ResultL
}); });
const handleLock = () => { const handleLock = () => {
const snapshot = {
lockedAt: new Date().toISOString(),
category,
roundId,
rankings: (projectRankings ?? []).map((p: any) => ({
projectId: p.id,
title: p.title,
teamName: p.teamName,
averageScore: p.averageScore,
evaluationCount: p.evaluationCount,
status: p.status,
})),
};
lockMutation.mutate({ lockMutation.mutate({
competitionId, competitionId,
roundId, roundId,
category, category,
resultSnapshot: {} // This would contain the actual results snapshot resultSnapshot: snapshot,
}); });
}; };

View File

@@ -0,0 +1,375 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
ChevronDown,
ChevronUp,
Loader2,
CheckCircle2,
Play,
Trophy,
AlertTriangle,
} from 'lucide-react'
type AwardShortlistProps = {
awardId: string
roundId: string
awardName: string
criteriaText?: string | null
eligibilityMode: string
shortlistSize: number
jobStatus?: string | null
jobTotal?: number | null
jobDone?: number | null
}
export function AwardShortlist({
awardId,
roundId,
awardName,
criteriaText,
eligibilityMode,
shortlistSize,
jobStatus,
jobTotal,
jobDone,
}: AwardShortlistProps) {
const [expanded, setExpanded] = useState(false)
const utils = trpc.useUtils()
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
const { data: shortlist, isLoading: isLoadingShortlist } = trpc.specialAward.listShortlist.useQuery(
{ awardId, perPage: 100 },
{ enabled: expanded && !isRunning }
)
const { data: jobPoll } = trpc.specialAward.getEligibilityJobStatus.useQuery(
{ awardId },
{ enabled: isRunning, refetchInterval: 3000 }
)
const runMutation = trpc.specialAward.runEligibilityForRound.useMutation({
onSuccess: () => {
toast.success('Eligibility evaluation started')
utils.specialAward.getEligibilityJobStatus.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const toggleMutation = trpc.specialAward.toggleShortlisted.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(data.shortlisted ? 'Added to shortlist' : 'Removed from shortlist')
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const bulkToggleMutation = trpc.specialAward.bulkToggleShortlisted.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(`${data.updated} projects ${data.shortlisted ? 'added to' : 'removed from'} shortlist`)
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const { data: awardRounds } = trpc.specialAward.listRounds.useQuery(
{ awardId },
{ enabled: expanded && eligibilityMode === 'SEPARATE_POOL' }
)
const hasAwardRounds = (awardRounds?.length ?? 0) > 0
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(
`Confirmed ${data.confirmedCount} projects` +
(data.routedCount > 0 ? `${data.routedCount} routed to award track` : '')
)
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const currentJobStatus = jobPoll?.eligibilityJobStatus ?? jobStatus
const currentJobDone = jobPoll?.eligibilityJobDone ?? jobDone
const currentJobTotal = jobPoll?.eligibilityJobTotal ?? jobTotal
const jobProgress = currentJobTotal && currentJobTotal > 0
? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100)
: 0
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
const someShortlisted = shortlistedCount > 0 && !allShortlisted
const handleBulkToggle = () => {
if (!shortlist) return
const projectIds = shortlist.eligibilities.map((e) => e.project.id)
const newValue = !allShortlisted
bulkToggleMutation.mutate({ awardId, projectIds, shortlisted: newValue })
}
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<div className="border rounded-lg">
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left">
<div className="flex items-center gap-3">
<Trophy className="h-5 w-5 text-amber-600" />
<div>
<h4 className="font-semibold text-sm">{awardName}</h4>
{criteriaText && (
<p className="text-xs text-muted-foreground line-clamp-1 max-w-md">
{criteriaText}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className={eligibilityMode === 'SEPARATE_POOL'
? 'bg-purple-50 text-purple-700 border-purple-200'
: 'bg-blue-50 text-blue-700 border-blue-200'
}>
{eligibilityMode === 'SEPARATE_POOL' ? 'Separate Pool' : 'Main Pool'}
</Badge>
{currentJobStatus === 'COMPLETED' && (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
Evaluated
</Badge>
)}
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t p-4 space-y-4">
{/* Job controls */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Evaluate PASSED projects against this award&apos;s criteria
</div>
<Button
size="sm"
onClick={() => runMutation.mutate({ awardId, roundId })}
disabled={runMutation.isPending || isRunning}
>
{isRunning ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Processing...</>
) : runMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Starting...</>
) : currentJobStatus === 'COMPLETED' ? (
<><Play className="h-4 w-4 mr-2" />Re-evaluate</>
) : (
<><Play className="h-4 w-4 mr-2" />Run Eligibility</>
)}
</Button>
</div>
{/* Progress bar */}
{isRunning && currentJobTotal && currentJobTotal > 0 && (
<div className="space-y-1">
<Progress value={jobProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-right">
{currentJobDone ?? 0} / {currentJobTotal} projects
</p>
</div>
)}
{/* Shortlist table */}
{expanded && currentJobStatus === 'COMPLETED' && (
<>
{isLoadingShortlist ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : shortlist && shortlist.eligibilities.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{shortlist.total} eligible projects
{shortlistedCount > 0 && (
<span className="text-muted-foreground ml-1">
({shortlistedCount} shortlisted)
</span>
)}
</p>
{shortlistedCount > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="default">
<CheckCircle2 className="h-4 w-4 mr-2" />
Confirm Shortlist ({shortlistedCount})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Shortlist</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
{eligibilityMode === 'SEPARATE_POOL'
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
}
</p>
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
</p>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => confirmMutation.mutate({ awardId })}
disabled={confirmMutation.isPending}
>
{confirmMutation.isPending ? 'Confirming...' : 'Confirm'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left w-8">#</th>
<th className="px-3 py-2 text-left">Project</th>
<th className="px-3 py-2 text-left w-24">Score</th>
<th className="px-3 py-2 text-left min-w-[300px]">Reasoning</th>
<th className="px-3 py-2 text-center w-20">
<div className="flex items-center justify-center gap-1">
<Checkbox
checked={allShortlisted ? true : someShortlisted ? 'indeterminate' : false}
onCheckedChange={handleBulkToggle}
disabled={bulkToggleMutation.isPending}
aria-label="Select all"
/>
<span className="text-xs">All</span>
</div>
</th>
</tr>
</thead>
<tbody>
{shortlist.eligibilities.map((e, i) => {
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
const isTop5 = i < shortlistSize
return (
<tr key={e.id} className={`border-t ${isTop5 ? 'bg-amber-50/50' : ''}`}>
<td className="px-3 py-2 text-muted-foreground font-mono">
{isTop5 ? (
<span className="text-amber-600 font-semibold">{i + 1}</span>
) : (
i + 1
)}
</td>
<td className="px-3 py-2">
<div>
<p className={`font-medium ${isTop5 ? 'text-amber-900' : ''}`}>
{e.project.title}
</p>
<p className="text-xs text-muted-foreground">
{[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}
</p>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<Progress
value={e.qualityScore ?? 0}
className="h-2 w-16"
/>
<span className="text-xs font-mono font-medium">
{e.qualityScore ?? 0}
</span>
</div>
</td>
<td className="px-3 py-2">
{reasoning ? (
<p className="text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed">
{reasoning}
</p>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2 text-center">
<Checkbox
checked={e.shortlisted}
onCheckedChange={() =>
toggleMutation.mutate({ awardId, projectId: e.project.id })
}
disabled={toggleMutation.isPending}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
No eligible projects found
</p>
)}
</>
)}
{/* Not yet evaluated */}
{expanded && !currentJobStatus && (
<p className="text-sm text-muted-foreground text-center py-4">
Click &quot;Run Eligibility&quot; to evaluate projects against this award&apos;s criteria
</p>
)}
{/* Failed */}
{currentJobStatus === 'FAILED' && (
<p className="text-sm text-red-600 text-center py-2">
Eligibility evaluation failed. Try again.
</p>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
)
}

View File

@@ -35,7 +35,6 @@ import {
Pencil, Pencil,
Trash2, Trash2,
FileText, FileText,
GripVertical,
FileCheck, FileCheck,
FileQuestion, FileQuestion,
} from 'lucide-react' } from 'lucide-react'

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -45,11 +45,11 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { import {
Award,
Play, Play,
Loader2, Loader2,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
AlertTriangle,
RefreshCw, RefreshCw,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -68,10 +68,12 @@ import {
FileText, FileText,
Brain, Brain,
ListFilter, ListFilter,
GripVertical, Settings2,
} from 'lucide-react' } from 'lucide-react'
import { motion, AnimatePresence } from 'motion/react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { AwardShortlist } from './award-shortlist'
type FilteringDashboardProps = { type FilteringDashboardProps = {
competitionId: string competitionId: string
@@ -93,6 +95,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [pollingJobId, setPollingJobId] = useState<string | null>(null) const [pollingJobId, setPollingJobId] = useState<string | null>(null)
const [jobRunning, setJobRunning] = useState(false)
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false) const [overrideDialogOpen, setOverrideDialogOpen] = useState(false)
const [overrideTarget, setOverrideTarget] = useState<{ id: string; name: string } | null>(null) const [overrideTarget, setOverrideTarget] = useState<{ id: string; name: string } | null>(null)
const [overrideOutcome, setOverrideOutcome] = useState<'PASSED' | 'FILTERED_OUT' | 'FLAGGED'>('PASSED') const [overrideOutcome, setOverrideOutcome] = useState<'PASSED' | 'FILTERED_OUT' | 'FLAGGED'>('PASSED')
@@ -103,17 +106,44 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
// AI criteria state
const [criteriaText, setCriteriaText] = useState('')
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER'>('FLAG')
const [batchSize, setBatchSize] = useState(20)
const [parallelBatches, setParallelBatches] = useState(3)
const [autoFilterThreshold, setAutoFilterThreshold] = useState(4)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [criteriaDirty, setCriteriaDirty] = useState(false)
const criteriaLoaded = useRef(false)
const utils = trpc.useUtils() const utils = trpc.useUtils()
// -- Queries -- // -- Queries --
const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery( const { data: round } = trpc.round.getById.useQuery({ id: roundId })
const roundConfig = (round?.configJson as Record<string, unknown>) ?? {}
const aiParseFiles = !!roundConfig.aiParseFiles
const updateRoundMutation = trpc.round.update.useMutation({
onSuccess: () => utils.round.getById.invalidate({ id: roundId }),
onError: (err) => toast.error(err.message),
})
// Dynamic refetch: all viewers get fast polling when a job is running (not just the one who started it)
const { data: latestJob } = trpc.filtering.getLatestJob.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 15_000 }, { refetchInterval: jobRunning ? 3_000 : 15_000 },
) )
const { data: latestJob, isLoading: jobLoading } = trpc.filtering.getLatestJob.useQuery( const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
// Sync jobRunning so fast polling kicks in for ALL viewers, not just the job starter
useEffect(() => {
setJobRunning(isRunning)
}, [isRunning])
const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 15_000 }, { refetchInterval: isRunning ? 3_000 : 15_000 },
) )
const { data: rules } = trpc.filtering.getRules.useQuery( const { data: rules } = trpc.filtering.getRules.useQuery(
@@ -128,7 +158,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
page, page,
perPage: 25, perPage: 25,
}, },
{ refetchInterval: 15_000 }, { refetchInterval: isRunning ? 3_000 : 15_000 },
) )
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery( const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
@@ -139,6 +169,20 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
}, },
) )
// Load AI criteria from existing rule
const aiRule = rules?.find((r: any) => r.ruleType === 'AI_SCREENING')
useEffect(() => {
if (aiRule && !criteriaLoaded.current) {
const config = (aiRule.configJson || {}) as Record<string, unknown>
setCriteriaText((config.criteriaText as string) || '')
setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER') || 'FLAG')
setBatchSize((config.batchSize as number) || 20)
setParallelBatches((config.parallelBatches as number) || 3)
setAutoFilterThreshold((config.autoFilterThreshold as number) || 4)
criteriaLoaded.current = true
}
}, [aiRule])
// Stop polling when job completes // Stop polling when job completes
useEffect(() => { useEffect(() => {
if (jobStatus && (jobStatus.status === 'COMPLETED' || jobStatus.status === 'FAILED')) { if (jobStatus && (jobStatus.status === 'COMPLETED' || jobStatus.status === 'FAILED')) {
@@ -162,6 +206,16 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
}, [latestJob]) }, [latestJob])
// -- Mutations -- // -- Mutations --
const createRuleMutation = trpc.filtering.createRule.useMutation({
onSuccess: () => utils.filtering.getRules.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
})
const updateRuleMutation = trpc.filtering.updateRule.useMutation({
onSuccess: () => utils.filtering.getRules.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
})
const startJobMutation = trpc.filtering.startJob.useMutation({ const startJobMutation = trpc.filtering.startJob.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
setPollingJobId(data.jobId) setPollingJobId(data.jobId)
@@ -202,6 +256,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
utils.project.list.invalidate() utils.project.list.invalidate()
toast.success( toast.success(
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` + `Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
(data.routedToAwards > 0 ? `, ${data.routedToAwards} routed to award tracks` : '') +
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '') (data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')
) )
if (data.categoryWarnings.length > 0) { if (data.categoryWarnings.length > 0) {
@@ -212,8 +267,61 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
}) })
// -- Handlers -- // -- Handlers --
const handleStartJob = () => { const handleSaveAndRun = async () => {
if (!criteriaText.trim()) {
toast.error('Please write screening criteria first')
return
}
const configJson = {
criteriaText,
action: aiAction,
batchSize,
parallelBatches,
...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }),
}
try {
if (aiRule) {
await updateRuleMutation.mutateAsync({ id: (aiRule as any).id, configJson })
} else {
await createRuleMutation.mutateAsync({
roundId,
name: 'AI Screening',
ruleType: 'AI_SCREENING',
configJson,
priority: 0,
})
}
setCriteriaDirty(false)
startJobMutation.mutate({ roundId }) startJobMutation.mutate({ roundId })
} catch {
// Error handled by mutation onError
}
}
const handleSaveCriteria = async () => {
const configJson = {
criteriaText,
action: aiAction,
batchSize,
parallelBatches,
...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }),
}
if (aiRule) {
await updateRuleMutation.mutateAsync({ id: (aiRule as any).id, configJson })
} else {
await createRuleMutation.mutateAsync({
roundId,
name: 'AI Screening',
ruleType: 'AI_SCREENING',
configJson,
priority: 0,
})
}
setCriteriaDirty(false)
toast.success('Criteria saved')
} }
const handleOverride = () => { const handleOverride = () => {
@@ -275,14 +383,25 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const parseAIData = (json: unknown): AIScreeningData | null => { const parseAIData = (json: unknown): AIScreeningData | null => {
if (!json || typeof json !== 'object') return null if (!json || typeof json !== 'object') return null
return json as AIScreeningData const obj = json as Record<string, unknown>
// aiScreeningJson is nested under rule ID: { [ruleId]: { outcome, confidence, ... } }
// Unwrap first entry if top-level keys don't include expected AI fields
if (!('outcome' in obj)) {
const keys = Object.keys(obj)
if (keys.length > 0) {
const inner = obj[keys[0]]
if (inner && typeof inner === 'object') return inner as AIScreeningData
}
return null
}
return obj as unknown as AIScreeningData
} }
// Is there a running job?
const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
const activeJob = jobStatus || (latestJob?.status === 'RUNNING' ? latestJob : null) const activeJob = jobStatus || (latestJob?.status === 'RUNNING' ? latestJob : null)
const hasResults = stats && stats.total > 0 const hasResults = stats && stats.total > 0
const hasRules = rules && rules.length > 0 const nonAiRules = rules?.filter((r: any) => r.ruleType !== 'AI_SCREENING') ?? []
const hasNonAiRules = nonAiRules.length > 0
const isSaving = createRuleMutation.isPending || updateRuleMutation.isPending
// Filter results by search query (client-side) // Filter results by search query (client-side)
const displayResults = resultsPage?.results.filter((r: any) => { const displayResults = resultsPage?.results.filter((r: any) => {
@@ -294,37 +413,74 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
) )
}) ?? [] }) ?? []
// ── Staggered reveal: animate new results in one by one ─────────────
const prevResultIdsRef = useRef<Set<string>>(new Set())
const isFirstLoad = useRef(true)
const resultStagger = useMemo(() => {
const stagger: Record<string, number> = {}
if (isFirstLoad.current && displayResults.length > 0) {
isFirstLoad.current = false
return stagger // No stagger on first load — show immediately
}
let newIdx = 0
for (const r of displayResults) {
if (!prevResultIdsRef.current.has((r as any).id)) {
stagger[(r as any).id] = newIdx++
}
}
return stagger
}, [displayResults])
// Update known IDs after render
useEffect(() => {
prevResultIdsRef.current = new Set(displayResults.map((r: any) => r.id))
}, [displayResults])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Job Control */} {/* Main Card: AI Screening Criteria + Controls */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div> <div>
<CardTitle className="text-base">AI Filtering</CardTitle> <CardTitle className="text-base flex items-center gap-2">
<Brain className="h-5 w-5 text-purple-600" />
AI Screening
</CardTitle>
<CardDescription> <CardDescription>
Run AI screening against {hasRules ? rules.length : 0} active rule{rules?.length !== 1 ? 's' : ''} Write all your screening criteria below the AI evaluates each project against them
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
{criteriaDirty && (
<Button <Button
onClick={handleStartJob} variant="outline"
disabled={isRunning || startJobMutation.isPending || !hasRules}
size="sm" size="sm"
onClick={handleSaveCriteria}
disabled={isSaving}
>
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Save
</Button>
)}
<Button
size="sm"
onClick={handleSaveAndRun}
disabled={isRunning || startJobMutation.isPending || !criteriaText.trim()}
> >
{isRunning ? ( {isRunning ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Running...</>
) : ( ) : (
<Play className="h-4 w-4 mr-2" /> <><Play className="h-4 w-4 mr-2" />Run Filtering</>
)} )}
{isRunning ? 'Running...' : 'Run Filtering'}
</Button> </Button>
{hasResults && ( {hasResults && !isRunning && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={isRunning || finalizeMutation.isPending}> <Button variant="outline" size="sm" disabled={finalizeMutation.isPending}>
<Shield className="h-4 w-4 mr-2" /> <Shield className="h-4 w-4 mr-2" />
Finalize Results Finalize
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@@ -334,7 +490,12 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
This will mark PASSED projects as eligible and FILTERED_OUT projects as rejected. This will mark PASSED projects as eligible and FILTERED_OUT projects as rejected.
{stats && ( {stats && (
<span className="block mt-2 font-medium"> <span className="block mt-2 font-medium">
{stats.passed} will pass, {stats.filteredOut} will be filtered out, {stats.flagged} flagged for review. {stats.passed - (stats.routedToAwards || 0)} will pass to main pool, {stats.filteredOut} will be filtered out{stats.flagged > 0 ? `, ${stats.flagged} flagged for review` : ''}.
{(stats.routedToAwards || 0) > 0 && (
<span className="block text-amber-700">
{stats.routedToAwards} already routed to award tracks (excluded from main pool).
</span>
)}
</span> </span>
)} )}
This action can be reversed but requires manual intervention. This action can be reversed but requires manual intervention.
@@ -353,16 +514,144 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Write all your screening criteria here. The AI will evaluate each project against these requirements.\n\nExample:\n1. Project must have clear ocean conservation impact\n2. Documents should be in English or French\n3. For Business Concepts, academic rigor is acceptable\n4. For African projects, apply a lower quality threshold (score >= 5/10)\n5. Flag any submissions that appear to be AI-generated filler`}
value={criteriaText}
onChange={(e) => { setCriteriaText(e.target.value); setCriteriaDirty(true) }}
rows={8}
className="text-sm font-mono"
/>
<p className="text-xs text-muted-foreground">
Available data per project: category, country, region, founded year, ocean issue, tags,
description, file details (type, pages, size, detected language), and team size.
</p>
{/* Advanced Settings */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
<Settings2 className="h-3.5 w-3.5" />
Advanced Settings
{advancedOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="space-y-3 rounded-lg border p-3 bg-muted/20">
<div className="grid grid-cols-3 gap-3">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Default Action</Label>
<Select value={aiAction} onValueChange={(v) => { setAiAction(v as 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER'); setCriteriaDirty(true) }}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AUTO_FILTER">Smart auto-filter</SelectItem>
<SelectItem value="FLAG">Flag for review</SelectItem>
<SelectItem value="REJECT">Auto-reject failures</SelectItem>
<SelectItem value="PASS">Auto-pass matches</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
<Input
type="number"
min={1}
max={50}
className="h-8 text-xs"
value={batchSize}
onChange={(e) => { setBatchSize(parseInt(e.target.value) || 20); setCriteriaDirty(true) }}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
<Input
type="number"
min={1}
max={10}
className="h-8 text-xs"
value={parallelBatches}
onChange={(e) => { setParallelBatches(parseInt(e.target.value) || 3); setCriteriaDirty(true) }}
/>
</div>
</div>
{aiAction === 'AUTO_FILTER' && (
<div className="rounded-md border border-purple-200 bg-purple-50/50 p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs font-medium">Quality threshold</Label>
<p className="text-xs text-muted-foreground">
Projects scoring at or below this are auto-rejected as spam/junk
</p>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={9}
className="h-8 w-16 text-xs text-center"
value={autoFilterThreshold}
onChange={(e) => {
const v = Math.min(9, Math.max(1, parseInt(e.target.value) || 4))
setAutoFilterThreshold(v)
setCriteriaDirty(true)
}}
/>
<span className="text-xs text-muted-foreground">/10</span>
</div>
</div>
<div className="grid grid-cols-3 gap-1.5 text-xs">
<div className="rounded bg-red-100 border border-red-200 px-2 py-1.5 text-center">
<span className="font-medium text-red-800">Auto-reject</span>
<p className="text-red-600 mt-0.5">Score 1-{autoFilterThreshold} or spam</p>
</div>
<div className="rounded bg-amber-100 border border-amber-200 px-2 py-1.5 text-center">
<span className="font-medium text-amber-800">Flag for review</span>
<p className="text-amber-600 mt-0.5">Score {autoFilterThreshold + 1}+ but fails criteria</p>
</div>
<div className="rounded bg-green-100 border border-green-200 px-2 py-1.5 text-center">
<span className="font-medium text-green-800">Auto-pass</span>
<p className="text-green-600 mt-0.5">Meets criteria</p>
</div>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
{/* AI Document Parsing — prominent toggle */}
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="ai-parse-files-filter" className="text-sm font-medium">
AI document parsing
</Label>
<p className="text-xs text-muted-foreground">
Allow AI to read uploaded file contents (PDF/text) for deeper analysis
</p>
</div>
<Switch
id="ai-parse-files-filter"
checked={aiParseFiles}
disabled={updateRoundMutation.isPending}
onCheckedChange={(checked) => {
updateRoundMutation.mutate({
id: roundId,
configJson: { ...roundConfig, aiParseFiles: checked },
})
}}
/>
</div>
{/* Job Progress */} {/* Job Progress */}
{isRunning && activeJob && ( {isRunning && activeJob && (
<CardContent className="pt-0"> <div className="space-y-2 pt-3 border-t">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> <span className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processing batch {activeJob.currentBatch} of {activeJob.totalBatches || '?'} Processing batch {activeJob.currentBatch} of {activeJob.totalBatches || '?'}
</span> </span>
<span className="font-mono"> <span className="font-mono text-sm">
{activeJob.processedCount}/{activeJob.totalProjects} {activeJob.processedCount}/{activeJob.totalProjects}
</span> </span>
</div> </div>
@@ -372,43 +661,34 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
: 0 : 0
} }
/> />
<p className="text-xs text-muted-foreground">
Results appear in the table below as each batch completes
</p>
</div> </div>
</CardContent>
)} )}
{/* Last job summary */} {/* Last job summary */}
{!isRunning && latestJob && latestJob.status === 'COMPLETED' && ( {!isRunning && latestJob && latestJob.status === 'COMPLETED' && (
<CardContent className="pt-0"> <div className="flex items-center gap-2 text-sm text-muted-foreground pt-3 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-600" /> <CheckCircle2 className="h-4 w-4 text-green-600" />
Last run completed: {latestJob.passedCount} passed, {latestJob.filteredCount} filtered, {latestJob.flaggedCount} flagged Last run: {latestJob.passedCount} passed, {latestJob.filteredCount} filtered, {latestJob.flaggedCount} flagged
<span className="text-xs"> <span className="text-xs">
({new Date(latestJob.completedAt!).toLocaleDateString()}) ({new Date(latestJob.completedAt!).toLocaleDateString()})
</span> </span>
</div> </div>
</CardContent>
)} )}
{!isRunning && latestJob && latestJob.status === 'FAILED' && ( {!isRunning && latestJob && latestJob.status === 'FAILED' && (
<CardContent className="pt-0"> <div className="flex items-center gap-2 text-sm text-red-600 pt-3 border-t">
<div className="flex items-center gap-2 text-sm text-red-600">
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
Last run failed: {latestJob.errorMessage || 'Unknown error'} Last run failed: {latestJob.errorMessage || 'Unknown error'}
</div> </div>
</CardContent>
)} )}
{!hasRules && (
<CardContent className="pt-0">
<p className="text-sm text-amber-600">
No active filtering rules configured. Add rules in the Configuration tab first.
</p>
</CardContent> </CardContent>
)}
</Card> </Card>
{/* Filtering Rules */} {/* Additional Rules (Field-Based & Document Checks) */}
<FilteringRulesSection roundId={roundId} /> <AdditionalRulesSection roundId={roundId} hasNonAiRules={hasNonAiRules} />
{/* Stats Cards */} {/* Stats Cards */}
{statsLoading ? ( {statsLoading ? (
@@ -418,7 +698,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
))} ))}
</div> </div>
) : stats && stats.total > 0 ? ( ) : stats && stats.total > 0 ? (
<div className="grid gap-4 grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 grid-cols-2 lg:grid-cols-6">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total</CardTitle> <CardTitle className="text-sm font-medium">Total</CardTitle>
@@ -438,6 +718,11 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<div className="text-2xl font-bold text-green-700">{stats.passed}</div> <div className="text-2xl font-bold text-green-700">{stats.passed}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{stats.total > 0 ? `${((stats.passed / stats.total) * 100).toFixed(0)}%` : '0%'} {stats.total > 0 ? `${((stats.passed / stats.total) * 100).toFixed(0)}%` : '0%'}
{stats.routedToAwards > 0 && (
<span className="block text-amber-600 mt-0.5">
{stats.passed - stats.routedToAwards} main + {stats.routedToAwards} award
</span>
)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -473,11 +758,23 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<p className="text-xs text-muted-foreground">Manual changes</p> <p className="text-xs text-muted-foreground">Manual changes</p>
</CardContent> </CardContent>
</Card> </Card>
{stats.routedToAwards > 0 && (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Award Track</CardTitle>
<Award className="h-4 w-4 text-amber-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-700">{stats.routedToAwards}</div>
<p className="text-xs text-muted-foreground">Routed to awards</p>
</CardContent>
</Card>
)}
</div> </div>
) : null} ) : null}
{/* Results Table */} {/* Results Table */}
{(hasResults || resultsLoading) && ( {(hasResults || resultsLoading || isRunning) && (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
@@ -485,6 +782,11 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<CardTitle className="text-base">Filtering Results</CardTitle> <CardTitle className="text-base">Filtering Results</CardTitle>
<CardDescription> <CardDescription>
Review AI screening outcomes &mdash; click a row to see reasoning, use quick buttons to override Review AI screening outcomes &mdash; click a row to see reasoning, use quick buttons to override
{isRunning && (
<span className="ml-2 text-purple-600 font-medium animate-pulse">
New results streaming in...
</span>
)}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -567,13 +869,26 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</div> </div>
{/* Rows */} {/* Rows */}
<AnimatePresence initial={false}>
{displayResults.map((result: any) => { {displayResults.map((result: any) => {
const ai = parseAIData(result.aiScreeningJson) const ai = parseAIData(result.aiScreeningJson)
const effectiveOutcome = result.finalOutcome || result.outcome const effectiveOutcome = result.finalOutcome || result.outcome
const isExpanded = expandedId === result.id const isExpanded = expandedId === result.id
const staggerIdx = resultStagger[result.id]
const isNew = staggerIdx !== undefined
return ( return (
<div key={result.id} className="border-b last:border-b-0"> <motion.div
key={result.id}
initial={isNew ? { opacity: 0, y: 16, scale: 0.98 } : false}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.35,
delay: isNew ? staggerIdx * 0.12 : 0,
ease: [0.25, 0.46, 0.45, 0.94],
}}
className="border-b last:border-b-0"
>
{/* Main Row */} {/* Main Row */}
<div <div
className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer" className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer"
@@ -592,13 +907,21 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)} )}
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5">
<Link <Link
href={`/admin/projects/${result.projectId}` as Route} href={`/admin/projects/${result.projectId}` as Route}
className="font-medium truncate block hover:underline text-foreground" className="font-medium truncate hover:underline text-foreground"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{result.project?.title || 'Unknown'} {result.project?.title || 'Unknown'}
</Link> </Link>
{result.project?.awardEligibilities?.length > 0 && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-amber-50 text-amber-700 border-amber-200 shrink-0">
<Award className="h-2.5 w-2.5 mr-0.5" />
{result.project.awardEligibilities[0].award.name}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{result.project?.teamName} {result.project?.teamName}
{result.project?.country && ` \u00b7 ${result.project.country}`} {result.project?.country && ` \u00b7 ${result.project.country}`}
@@ -607,7 +930,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</div> </div>
<div> <div>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{result.project?.competitionCategory || '\u2014'} {formatCategory(result.project?.competitionCategory) || '\u2014'}
</Badge> </Badge>
</div> </div>
<div> <div>
@@ -781,9 +1104,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</div> </div>
</div> </div>
)} )}
</div> </motion.div>
) )
})} })}
</AnimatePresence>
{/* Pagination */} {/* Pagination */}
{resultsPage && resultsPage.totalPages > 1 && ( {resultsPage && resultsPage.totalPages > 1 && (
@@ -816,10 +1140,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground mb-3" /> <Sparkles className="h-8 w-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{searchQuery.trim() ? 'No results match your search' : 'No results yet'} {searchQuery.trim() ? 'No results match your search' : isRunning ? 'Results will appear here as batches complete' : 'No results yet'}
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{searchQuery.trim() ? 'Try a different search term' : 'Run the filtering job to screen projects'} {searchQuery.trim() ? 'Try a different search term' : isRunning ? 'The AI is processing projects...' : 'Run filtering to screen projects'}
</p> </p>
</div> </div>
)} )}
@@ -827,6 +1151,9 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
</Card> </Card>
)} )}
{/* Special Award Tracks */}
{hasResults && <AwardTracksSection competitionId={competitionId} roundId={roundId} />}
{/* Single Override Dialog (with reason) */} {/* Single Override Dialog (with reason) */}
<Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}> <Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}>
<DialogContent> <DialogContent>
@@ -922,6 +1249,18 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
) )
} }
// -- Helpers --
const categoryLabels: Record<string, string> = {
BUSINESS_CONCEPT: 'Concept',
STARTUP: 'Startup',
}
function formatCategory(cat: string | null | undefined): string {
if (!cat) return ''
return categoryLabels[cat] ?? cat.charAt(0) + cat.slice(1).toLowerCase().replace(/_/g, ' ')
}
// -- Sub-components -- // -- Sub-components --
function OutcomeBadge({ outcome, overridden }: { outcome: string; overridden: boolean }) { function OutcomeBadge({ outcome, overridden }: { outcome: string; overridden: boolean }) {
@@ -948,14 +1287,13 @@ function ConfidenceIndicator({ value }: { value: number }) {
) )
} }
// ─── Filtering Rules Section ──────────────────────────────────────────────── // ─── Additional Rules Section (Field-Based & Document Checks only) ──────────
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING' type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK'
const RULE_TYPE_META: Record<RuleType, { label: string; icon: typeof ListFilter; color: string; description: string }> = { const RULE_TYPE_META: Record<RuleType, { label: string; icon: typeof ListFilter; color: string; description: string }> = {
FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' }, FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' },
DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' }, DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' },
AI_SCREENING: { label: 'AI Screening', icon: Brain, color: 'bg-purple-100 text-purple-800 border-purple-200', description: 'GPT evaluates projects against natural language criteria' },
} }
const FIELD_OPTIONS = [ const FIELD_OPTIONS = [
@@ -997,11 +1335,6 @@ type RuleFormData = {
maxPages: number | '' maxPages: number | ''
maxPagesByFileType: Record<string, number> maxPagesByFileType: Record<string, number>
docAction: 'PASS' | 'REJECT' | 'FLAG' docAction: 'PASS' | 'REJECT' | 'FLAG'
// AI_SCREENING
criteriaText: string
aiAction: 'PASS' | 'REJECT' | 'FLAG'
batchSize: number
parallelBatches: number
} }
const DEFAULT_FORM: RuleFormData = { const DEFAULT_FORM: RuleFormData = {
@@ -1016,10 +1349,6 @@ const DEFAULT_FORM: RuleFormData = {
maxPages: '', maxPages: '',
maxPagesByFileType: {}, maxPagesByFileType: {},
docAction: 'REJECT', docAction: 'REJECT',
criteriaText: '',
aiAction: 'FLAG',
batchSize: 20,
parallelBatches: 1,
} }
function buildConfigJson(form: RuleFormData): Record<string, unknown> { function buildConfigJson(form: RuleFormData): Record<string, unknown> {
@@ -1044,13 +1373,6 @@ function buildConfigJson(form: RuleFormData): Record<string, unknown> {
if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType
return config return config
} }
case 'AI_SCREENING':
return {
criteriaText: form.criteriaText,
action: form.aiAction,
batchSize: form.batchSize,
parallelBatches: form.parallelBatches,
}
} }
} }
@@ -1075,21 +1397,13 @@ function parseConfigToForm(rule: { name: string; ruleType: string; configJson: u
maxPagesByFileType: (config.maxPagesByFileType as Record<string, number>) || {}, maxPagesByFileType: (config.maxPagesByFileType as Record<string, number>) || {},
docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT', docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
} }
case 'AI_SCREENING':
return {
...base,
criteriaText: (config.criteriaText as string) || '',
aiAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG',
batchSize: (config.batchSize as number) || 20,
parallelBatches: (config.parallelBatches as number) || 1,
}
default: default:
return base return base
} }
} }
function FilteringRulesSection({ roundId }: { roundId: string }) { function AdditionalRulesSection({ roundId, hasNonAiRules }: { roundId: string; hasNonAiRules: boolean }) {
const [isOpen, setIsOpen] = useState(true) const [isOpen, setIsOpen] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editingRule, setEditingRule] = useState<string | null>(null) const [editingRule, setEditingRule] = useState<string | null>(null)
const [form, setForm] = useState<RuleFormData>({ ...DEFAULT_FORM }) const [form, setForm] = useState<RuleFormData>({ ...DEFAULT_FORM })
@@ -1097,11 +1411,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data: rules, isLoading } = trpc.filtering.getRules.useQuery( const { data: allRules, isLoading } = trpc.filtering.getRules.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 30_000 }, { refetchInterval: 30_000 },
) )
// Only show non-AI rules
const rules = allRules?.filter((r: any) => r.ruleType !== 'AI_SCREENING') ?? []
const createMutation = trpc.filtering.createRule.useMutation({ const createMutation = trpc.filtering.createRule.useMutation({
onSuccess: () => { onSuccess: () => {
utils.filtering.getRules.invalidate({ roundId }) utils.filtering.getRules.invalidate({ roundId })
@@ -1156,12 +1473,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
const openCreate = () => { const openCreate = () => {
setEditingRule(null) setEditingRule(null)
setForm({ ...DEFAULT_FORM, priority: (rules?.length ?? 0) }) setForm({ ...DEFAULT_FORM, priority: rules.length })
setDialogOpen(true) setDialogOpen(true)
} }
const meta = RULE_TYPE_META[form.ruleType]
return ( return (
<> <>
<Collapsible open={isOpen} onOpenChange={setIsOpen}> <Collapsible open={isOpen} onOpenChange={setIsOpen}>
@@ -1172,9 +1487,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ListFilter className="h-5 w-5 text-[#053d57]" /> <ListFilter className="h-5 w-5 text-[#053d57]" />
<div> <div>
<CardTitle className="text-base">Filtering Rules</CardTitle> <CardTitle className="text-base">Additional Rules</CardTitle>
<CardDescription> <CardDescription>
{rules?.length ?? 0} active rule{(rules?.length ?? 0) !== 1 ? 's' : ''} &mdash; executed in priority order {rules.length} field-based / document check rule{rules.length !== 1 ? 's' : ''} applied alongside AI screening
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -1199,7 +1514,7 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
<div className="space-y-2"> <div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)} {[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div> </div>
) : rules && rules.length > 0 ? ( ) : rules.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{rules.map((rule: any, idx: number) => { {rules.map((rule: any, idx: number) => {
const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED
@@ -1212,7 +1527,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
className="flex items-center gap-3 rounded-lg border p-3 bg-background hover:bg-muted/30 transition-colors group" className="flex items-center gap-3 rounded-lg border p-3 bg-background hover:bg-muted/30 transition-colors group"
> >
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50" />
<span className="text-xs font-mono w-5 text-center">{idx + 1}</span> <span className="text-xs font-mono w-5 text-center">{idx + 1}</span>
</div> </div>
@@ -1234,17 +1548,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
{config.minFileCount ? `Min ${config.minFileCount} files` : ''} {config.minFileCount ? `Min ${config.minFileCount} files` : ''}
{config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''} {config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''}
{config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''} {config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''}
{config.maxPagesByFileType && Object.keys(config.maxPagesByFileType as object).length > 0
? ` \u00b7 Page limits per type`
: ''}
{' \u2192 '}{config.action as string} {' \u2192 '}{config.action as string}
</> </>
)} )}
{rule.ruleType === 'AI_SCREENING' && (
<>
{((config.criteriaText as string) || '').substring(0, 80)}{((config.criteriaText as string) || '').length > 80 ? '...' : ''} &rarr; {config.action as string}
</>
)}
</p> </p>
</div> </div>
@@ -1277,15 +1583,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
})} })}
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-6 text-center">
<ListFilter className="h-8 w-8 text-muted-foreground mb-3" /> <ListFilter className="h-6 w-6 text-muted-foreground mb-2" />
<p className="text-sm font-medium">No filtering rules configured</p> <p className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground mt-1 mb-3"> No additional rules AI screening criteria handle everything
Add rules to define how projects are screened
</p> </p>
<Button variant="outline" size="sm" onClick={openCreate}> <Button variant="outline" size="sm" className="mt-2" onClick={openCreate}>
<Plus className="h-3.5 w-3.5 mr-1" /> <Plus className="h-3.5 w-3.5 mr-1" />
Add First Rule Add Rule
</Button> </Button>
</div> </div>
)} )}
@@ -1301,9 +1606,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
}}> }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Filtering Rule'}</DialogTitle> <DialogTitle>{editingRule ? 'Edit Rule' : 'Create Rule'}</DialogTitle>
<DialogDescription> <DialogDescription>
{editingRule ? 'Update this filtering rule configuration' : 'Define a new rule for screening projects'} {editingRule ? 'Update this filtering rule' : 'Add a field-based or document check rule'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -1329,10 +1634,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
</div> </div>
</div> </div>
{/* Rule Type Selector */} {/* Rule Type Selector (only Field-Based and Document Check) */}
<div> <div>
<Label className="text-sm font-medium mb-2 block">Rule Type</Label> <Label className="text-sm font-medium mb-2 block">Rule Type</Label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-2 gap-2">
{(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => { {(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => {
const Icon = m.icon const Icon = m.icon
const selected = form.ruleType === type const selected = form.ruleType === type
@@ -1361,7 +1666,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
<div className="space-y-3 rounded-lg border p-4 bg-muted/20"> <div className="space-y-3 rounded-lg border p-4 bg-muted/20">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium">Conditions</Label> <Label className="text-sm font-medium">Conditions</Label>
<div className="flex items-center gap-2">
<Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}> <Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}>
<SelectTrigger className="w-20 h-7 text-xs"> <SelectTrigger className="w-20 h-7 text-xs">
<SelectValue /> <SelectValue />
@@ -1372,7 +1676,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
{form.conditions.map((cond, i) => { {form.conditions.map((cond, i) => {
const fieldMeta = FIELD_OPTIONS.find((f) => f.value === cond.field) const fieldMeta = FIELD_OPTIONS.find((f) => f.value === cond.field)
@@ -1586,62 +1889,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
</div> </div>
</div> </div>
)} )}
{form.ruleType === 'AI_SCREENING' && (
<div className="space-y-4 rounded-lg border p-4 bg-muted/20">
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Screening Criteria</Label>
<Textarea
placeholder="Write the criteria the AI should evaluate against. Example:&#10;&#10;1. Ocean conservation impact must be clearly stated&#10;2. Documents must be in English&#10;3. For Business Concepts, academic rigor is acceptable&#10;4. For African projects, apply a lower quality threshold (score >= 5/10)"
value={form.criteriaText}
onChange={(e) => setForm((f) => ({ ...f, criteriaText: e.target.value }))}
rows={8}
className="text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size, detected language), and team size.
</p>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Action</Label>
<Select value={form.aiAction} onValueChange={(v) => setForm((f) => ({ ...f, aiAction: v as any }))}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FLAG">Flag for review</SelectItem>
<SelectItem value="REJECT">Auto-reject</SelectItem>
<SelectItem value="PASS">Auto-pass</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
<Input
type="number"
min={1}
max={50}
className="h-8 text-xs"
value={form.batchSize}
onChange={(e) => setForm((f) => ({ ...f, batchSize: parseInt(e.target.value) || 20 }))}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
<Input
type="number"
min={1}
max={10}
className="h-8 text-xs"
value={form.parallelBatches}
onChange={(e) => setForm((f) => ({ ...f, parallelBatches: parseInt(e.target.value) || 1 }))}
/>
</div>
</div>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
@@ -1685,3 +1932,45 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
</> </>
) )
} }
// ─── Award Tracks Section ───────────────────────────────────────────────────
function AwardTracksSection({ competitionId, roundId }: { competitionId: string; roundId: string }) {
const { data: awards, isLoading } = trpc.specialAward.listForRound.useQuery(
{ roundId },
{ enabled: !!roundId }
)
if (isLoading) return null
if (!awards || awards.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Award className="h-5 w-5 text-amber-600" />
Special Award Tracks
</CardTitle>
<CardDescription>
Award eligibility is evaluated automatically during AI filtering. Use Run Eligibility to re-evaluate.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{awards.map((award) => (
<AwardShortlist
key={award.id}
awardId={award.id}
roundId={roundId}
awardName={award.name}
criteriaText={award.criteriaText}
eligibilityMode={award.eligibilityMode}
shortlistSize={award.shortlistSize}
jobStatus={award.eligibilityJobStatus}
jobTotal={award.eligibilityJobTotal}
jobDone={award.eligibilityJobDone}
/>
))}
</CardContent>
</Card>
)
}

View File

@@ -9,6 +9,9 @@ import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -85,6 +88,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null) const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false) const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
const [quickAddOpen, setQuickAddOpen] = useState(false) const [quickAddOpen, setQuickAddOpen] = useState(false)
const [addProjectOpen, setAddProjectOpen] = useState(false)
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -274,16 +278,10 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}> <Button size="sm" variant="outline" onClick={() => { setAddProjectOpen(true) }}>
<Plus className="h-4 w-4 mr-1.5" /> <Plus className="h-4 w-4 mr-1.5" />
Quick Add Add Project
</Button> </Button>
<Link href={poolLink}>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1.5" />
Add from Pool
</Button>
</Link>
</div> </div>
</div> </div>
@@ -330,7 +328,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
{/* Table */} {/* Table */}
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
{/* Header */} {/* Header */}
<div className="grid grid-cols-[40px_1fr_140px_120px_100px_48px] gap-2 px-4 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-b"> <div className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-b">
<div> <div>
<Checkbox <Checkbox
checked={filtered.length > 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))} checked={filtered.length > 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))}
@@ -339,6 +337,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</div> </div>
<div>Project</div> <div>Project</div>
<div>Category</div> <div>Category</div>
<div>Country</div>
<div>State</div> <div>State</div>
<div>Entered</div> <div>Entered</div>
<div /> <div />
@@ -351,7 +350,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
return ( return (
<div <div
key={ps.id} key={ps.id}
className="grid grid-cols-[40px_1fr_140px_120px_100px_48px] gap-2 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 text-sm" className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 text-sm"
> >
<div> <div>
<Checkbox <Checkbox
@@ -373,6 +372,9 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
{ps.project?.competitionCategory || '—'} {ps.project?.competitionCategory || '—'}
</Badge> </Badge>
</div> </div>
<div className="text-xs text-muted-foreground truncate">
{ps.project?.country || '—'}
</div>
<div> <div>
<Badge variant="outline" className={`text-xs ${cfg.color}`}> <Badge variant="outline" className={`text-xs ${cfg.color}`}>
<StateIcon className="h-3 w-3 mr-1" /> <StateIcon className="h-3 w-3 mr-1" />
@@ -432,7 +434,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
)} )}
</div> </div>
{/* Quick Add Dialog */} {/* Quick Add Dialog (legacy, kept for empty state) */}
<QuickAddDialog <QuickAddDialog
open={quickAddOpen} open={quickAddOpen}
onOpenChange={setQuickAddOpen} onOpenChange={setQuickAddOpen}
@@ -443,6 +445,17 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
}} }}
/> />
{/* Add Project Dialog (Create New + From Pool) */}
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
{/* Single Remove Confirmation */} {/* Single Remove Confirmation */}
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}> <AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
<AlertDialogContent> <AlertDialogContent>
@@ -669,3 +682,287 @@ function QuickAddDialog({
</Dialog> </Dialog>
) )
} }
/**
* Add Project Dialog — two tabs: "Create New" and "From Pool".
* Create New: form to create a project and assign it directly to the round.
* From Pool: search existing projects not yet in this round and assign them.
*/
function AddProjectDialog({
open,
onOpenChange,
roundId,
competitionId,
onAssigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
competitionId: string
onAssigned: () => void
}) {
const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
// ── Create New tab state ──
const [title, setTitle] = useState('')
const [teamName, setTeamName] = useState('')
const [description, setDescription] = useState('')
const [country, setCountry] = useState('')
const [category, setCategory] = useState<string>('')
// ── From Pool tab state ──
const [poolSearch, setPoolSearch] = useState('')
const [selectedPoolIds, setSelectedPoolIds] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
// Get the competition to find programId (for pool search)
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: open && !!competitionId },
)
const programId = (competition as any)?.programId || ''
// Pool query
const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
{
programId,
excludeRoundId: roundId,
search: poolSearch.trim() || undefined,
perPage: 50,
},
{ enabled: open && activeTab === 'pool' && !!programId },
)
// Create mutation
const createMutation = trpc.project.createAndAssignToRound.useMutation({
onSuccess: () => {
toast.success('Project created and added to round')
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
// Assign from pool mutation
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (data) => {
toast.success(`${data.assignedCount} project(s) added to round`)
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
const resetAndClose = () => {
setTitle('')
setTeamName('')
setDescription('')
setCountry('')
setCategory('')
setPoolSearch('')
setSelectedPoolIds(new Set())
onOpenChange(false)
}
const handleCreate = () => {
if (!title.trim()) return
createMutation.mutate({
title: title.trim(),
teamName: teamName.trim() || undefined,
description: description.trim() || undefined,
country: country.trim() || undefined,
competitionCategory: category === 'STARTUP' || category === 'BUSINESS_CONCEPT' ? category : undefined,
roundId,
})
}
const handleAssignFromPool = () => {
if (selectedPoolIds.size === 0) return
assignMutation.mutate({
projectIds: Array.from(selectedPoolIds),
roundId,
})
}
const togglePoolProject = (id: string) => {
setSelectedPoolIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const isMutating = createMutation.isPending || assignMutation.isPending
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) resetAndClose()
else onOpenChange(true)
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Project to Round</DialogTitle>
<DialogDescription>
Create a new project or select existing ones to add to this round.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="create">Create New</TabsTrigger>
<TabsTrigger value="pool">From Pool</TabsTrigger>
</TabsList>
{/* ── Create New Tab ── */}
<TabsContent value="create" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="add-project-title">Title *</Label>
<Input
id="add-project-title"
placeholder="Project title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="add-project-team">Team Name</Label>
<Input
id="add-project-team"
placeholder="Team or organization name"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="add-project-country">Country</Label>
<Input
id="add-project-country"
placeholder="e.g. France"
value={country}
onChange={(e) => setCountry(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="add-project-desc">Description</Label>
<Input
id="add-project-desc"
placeholder="Brief description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleCreate}
disabled={!title.trim() || isMutating}
>
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create & Add to Round
</Button>
</DialogFooter>
</TabsContent>
{/* ── From Pool Tab ── */}
<TabsContent value="pool" className="space-y-4 mt-4">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by project title or team..."
value={poolSearch}
onChange={(e) => setPoolSearch(e.target.value)}
className="pl-8"
/>
</div>
<ScrollArea className="h-[320px] rounded-md border">
<div className="p-2 space-y-0.5">
{poolLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!poolLoading && poolResults?.projects.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{poolSearch.trim() ? `No projects found matching "${poolSearch}"` : 'No projects available to add'}
</p>
)}
{poolResults?.projects.map((project: any) => {
const isSelected = selectedPoolIds.has(project.id)
return (
<label
key={project.id}
className={`flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePoolProject(project.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.country && <> &middot; {project.country}</>}
</p>
</div>
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
</Badge>
)}
</div>
</label>
)
})}
</div>
</ScrollArea>
{poolResults && poolResults.total > 50 && (
<p className="text-xs text-muted-foreground text-center">
Showing 50 of {poolResults.total} &mdash; refine your search for more specific results
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleAssignFromPool}
disabled={selectedPoolIds.size === 0 || isMutating}
>
{assignMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedPoolIds.size <= 1
? 'Add to Round'
: `Add ${selectedPoolIds.size} Projects to Round`
}
</Button>
</DialogFooter>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,654 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
type SubmissionWindowManagerProps = {
competitionId: string
roundId: string
}
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingWindow, setEditingWindow] = useState<string | null>(null)
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
// Create form state
const [createForm, setCreateForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
})
// Edit form state
const [editForm, setEditForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
sortOrder: 1,
})
const utils = trpc.useUtils()
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
id: competitionId,
})
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window created')
setIsCreateOpen(false)
// Reset form
setCreateForm({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE',
graceHours: 0,
lockOnClose: true,
})
},
onError: (err) => toast.error(err.message),
})
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window updated')
setEditingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window deleted')
setDeletingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window opened')
},
onError: (err) => toast.error(err.message),
})
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window closed')
},
onError: (err) => toast.error(err.message),
})
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window locked')
},
onError: (err) => toast.error(err.message),
})
const handleCreateNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setCreateForm({ ...createForm, name: value, slug: autoSlug })
}
const handleEditNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setEditForm({ ...editForm, name: value, slug: autoSlug })
}
const handleCreate = () => {
if (!createForm.name || !createForm.slug) {
toast.error('Name and slug are required')
return
}
createWindowMutation.mutate({
competitionId,
name: createForm.name,
slug: createForm.slug,
roundNumber: createForm.roundNumber,
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
deadlinePolicy: createForm.deadlinePolicy,
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
lockOnClose: createForm.lockOnClose,
})
}
const handleEdit = () => {
if (!editingWindow) return
if (!editForm.name || !editForm.slug) {
toast.error('Name and slug are required')
return
}
updateWindowMutation.mutate({
id: editingWindow,
name: editForm.name,
slug: editForm.slug,
roundNumber: editForm.roundNumber,
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
deadlinePolicy: editForm.deadlinePolicy,
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
lockOnClose: editForm.lockOnClose,
sortOrder: editForm.sortOrder,
})
}
const handleDelete = () => {
if (!deletingWindow) return
deleteWindowMutation.mutate({ id: deletingWindow })
}
const openEditDialog = (window: any) => {
setEditForm({
name: window.name,
slug: window.slug,
roundNumber: window.roundNumber,
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
deadlinePolicy: 'HARD_DEADLINE', // Not available in query, use default
graceHours: 0, // Not available in query, use default
lockOnClose: true, // Not available in query, use default
sortOrder: 1, // Not available in query, use default
})
setEditingWindow(window.id)
}
const formatDate = (date: Date | null | undefined) => {
if (!date) return 'Not set'
return format(new Date(date), 'MMM d, yyyy h:mm a')
}
const windows = competition?.submissionWindows ?? []
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base">Submission Windows</CardTitle>
<p className="text-sm text-muted-foreground">
File upload windows for this round
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-1" />
Create Window
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="create-name">Window Name</Label>
<Input
id="create-name"
placeholder="e.g., Round 1 Submissions"
value={createForm.name}
onChange={(e) => handleCreateNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-slug">Slug</Label>
<Input
id="create-slug"
placeholder="e.g., round-1-submissions"
value={createForm.slug}
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-roundNumber">Round Number</Label>
<Input
id="create-roundNumber"
type="number"
min={1}
value={createForm.roundNumber}
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
<Input
id="create-windowOpenAt"
type="datetime-local"
value={createForm.windowOpenAt}
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
<Input
id="create-windowCloseAt"
type="datetime-local"
value={createForm.windowCloseAt}
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
<Select
value={createForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setCreateForm({ ...createForm, deadlinePolicy: value })
}
>
<SelectTrigger id="create-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{createForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="create-graceHours">Grace Hours</Label>
<Input
id="create-graceHours"
type="number"
min={0}
value={createForm.graceHours}
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="create-lockOnClose"
checked={createForm.lockOnClose}
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
/>
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleCreate}
disabled={createWindowMutation.isPending}
>
{createWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Create
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading windows...
</div>
) : windows.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No submission windows yet. Create one to enable file uploads.
</div>
) : (
<div className="space-y-2">
{windows.map((window) => {
const isPending = !window.windowOpenAt
const isOpen = window.windowOpenAt && !window.windowCloseAt
const isClosed = window.windowCloseAt && !window.isLocked
const isLocked = window.isLocked
return (
<div
key={window.id}
className="flex flex-col gap-3 border rounded-lg p-3"
>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{window.name}</p>
{isPending && (
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
Pending
</Badge>
)}
{isOpen && (
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
Open
</Badge>
)}
{isClosed && (
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
Closed
</Badge>
)}
{isLocked && (
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
Locked
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Round {window.roundNumber}</span>
<span></span>
<span>{window._count.fileRequirements} requirements</span>
<span></span>
<span>{window._count.projectFiles} files</span>
</div>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Open: {formatDate(window.windowOpenAt)}</span>
<span></span>
<span>Close: {formatDate(window.windowCloseAt)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<Button
size="sm"
variant="ghost"
onClick={() => openEditDialog(window)}
className="h-8 px-2"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeletingWindow(window.id)}
className="h-8 px-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
{isPending && (
<Button
size="sm"
variant="outline"
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
disabled={openWindowMutation.isPending}
>
{openWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Unlock className="h-3 w-3 mr-1" />
)}
Open
</Button>
)}
{isOpen && (
<Button
size="sm"
variant="outline"
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
disabled={closeWindowMutation.isPending}
>
{closeWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Lock className="h-3 w-3 mr-1" />
)}
Close
</Button>
)}
{isClosed && (
<Button
size="sm"
variant="outline"
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
disabled={lockWindowMutation.isPending}
>
{lockWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<LockKeyhole className="h-3 w-3 mr-1" />
)}
Lock
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Window Name</Label>
<Input
id="edit-name"
placeholder="e.g., Round 1 Submissions"
value={editForm.name}
onChange={(e) => handleEditNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-slug">Slug</Label>
<Input
id="edit-slug"
placeholder="e.g., round-1-submissions"
value={editForm.slug}
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-roundNumber">Round Number</Label>
<Input
id="edit-roundNumber"
type="number"
min={1}
value={editForm.roundNumber}
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
<Input
id="edit-windowOpenAt"
type="datetime-local"
value={editForm.windowOpenAt}
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
<Input
id="edit-windowCloseAt"
type="datetime-local"
value={editForm.windowCloseAt}
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
<Select
value={editForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setEditForm({ ...editForm, deadlinePolicy: value })
}
>
<SelectTrigger id="edit-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{editForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="edit-graceHours">Grace Hours</Label>
<Input
id="edit-graceHours"
type="number"
min={0}
value={editForm.graceHours}
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="edit-lockOnClose"
checked={editForm.lockOnClose}
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
/>
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sortOrder">Sort Order</Label>
<Input
id="edit-sortOrder"
type="number"
min={1}
value={editForm.sortOrder}
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setEditingWindow(null)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleEdit}
disabled={updateWindowMutation.isPending}
>
{updateWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Save Changes
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Submission Window</DialogTitle>
<DialogDescription>
Are you sure you want to delete this submission window? This action cannot be undone.
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
<span className="block mt-2 text-destructive font-medium">
Warning: This window has uploaded files and cannot be deleted until they are removed.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeletingWindow(null)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteWindowMutation.isPending}
>
{deleteWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -46,12 +46,9 @@ export function DeliberationConfig({ config, onChange, juryGroups }: Deliberatio
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
<Input <p className="text-sm text-muted-foreground italic">
id="juryGroupId" No jury groups available. Create one in the Juries section first.
placeholder="Jury group ID" </p>
value={(config.juryGroupId as string) ?? ''}
onChange={(e) => update('juryGroupId', e.target.value)}
/>
)} )}
</div> </div>
</CardContent> </CardContent>

View File

@@ -143,6 +143,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
/> />
</div> </div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireDocumentUpload">Require Document Upload</Label>
<p className="text-xs text-muted-foreground">Applicants must upload documents for this evaluation round (disable if documents were uploaded in a previous round)</p>
</div>
<Switch
id="requireDocumentUpload"
checked={(config.requireDocumentUpload as boolean) ?? false}
onCheckedChange={(v) => update('requireDocumentUpload', v)}
/>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label htmlFor="peerReviewEnabled">Peer Review</Label> <Label htmlFor="peerReviewEnabled">Peer Review</Label>

View File

@@ -0,0 +1,140 @@
// Brand colors from CLAUDE.md
export const BRAND_DARK_BLUE = '#053d57'
export const BRAND_RED = '#de0f1e'
export const BRAND_TEAL = '#557f8c'
export const BRAND_WHITE = '#fefefe'
// Extended palette derived from brand
export const BRAND_COLORS = [
'#053d57', // Dark Blue
'#de0f1e', // Red
'#557f8c', // Teal
'#1e7a8a', // Deep Teal
'#c4453a', // Coral
'#3a6f7f', // Mid Teal
'#8b1a24', // Dark Red
'#2d8659', // Sea Green
'#7c9aa6', // Light Teal
'#a83240', // Rose
] as const
// Tremor named colors for chart components
// These are the official Tremor palette names that render correctly
export const TREMOR_BRAND = 'blue' as const
export const TREMOR_ACCENT = 'indigo' as const
export const TREMOR_CHART_COLORS = [
'blue',
'emerald',
'amber',
'violet',
'rose',
'indigo',
'sky',
'fuchsia',
'lime',
'orange',
] as const
// Donut / status chart colors (mapped to Tremor names)
// Covers both global ProjectStatus and round-level ProjectRoundState values
export const TREMOR_STATUS_COLORS: Record<string, string> = {
// Global project statuses
SUBMITTED: 'sky',
ELIGIBLE: 'blue',
ASSIGNED: 'violet',
SEMIFINALIST: 'amber',
FINALIST: 'emerald',
REJECTED: 'rose',
DRAFT: 'gray',
WITHDRAWN: 'slate',
// Round-level states (ProjectRoundState)
PENDING: 'sky',
IN_PROGRESS: 'blue',
PASSED: 'emerald',
COMPLETED: 'indigo',
// Evaluation review states
FULLY_REVIEWED: 'emerald',
PARTIALLY_REVIEWED: 'amber',
NOT_REVIEWED: 'rose',
}
// Project status colors — mapped to actual ProjectStatus enum values
export const STATUS_COLORS: Record<string, string> = {
SUBMITTED: '#557f8c', // Teal
ELIGIBLE: '#053d57', // Dark Blue
ASSIGNED: '#1e7a8a', // Deep Teal
SEMIFINALIST: '#c4453a', // Coral
FINALIST: '#2d8659', // Sea Green
REJECTED: '#de0f1e', // Red
DRAFT: '#9ca3af', // Gray
WITHDRAWN: '#6b7280', // Dark Gray
// Evaluation review states
FULLY_REVIEWED: '#2d8659', // Sea Green
PARTIALLY_REVIEWED: '#d97706', // Amber
NOT_REVIEWED: '#de0f1e', // Red
}
// Human-readable status labels
export const STATUS_LABELS: Record<string, string> = {
SUBMITTED: 'Submitted',
ELIGIBLE: 'In-Competition',
ASSIGNED: 'Special Award',
SEMIFINALIST: 'Semi-finalist',
FINALIST: 'Finalist',
REJECTED: 'Rejected',
DRAFT: 'Draft',
WITHDRAWN: 'Withdrawn',
// Round-level states
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
COMPLETED: 'Completed',
// Evaluation review states
FULLY_REVIEWED: 'Fully Reviewed',
PARTIALLY_REVIEWED: 'Partially Reviewed',
NOT_REVIEWED: 'Not Reviewed',
}
/**
* Score gradient: Red (low) → Teal (mid) → Dark Blue (high)
* for scores on a 1-10 scale
*/
export function scoreGradient(score: number): string {
const t = Math.max(0, Math.min(1, (score - 1) / 9))
if (t < 0.5) {
// Red → Teal (0 → 0.5)
const p = t * 2
return lerpColor(BRAND_RED, BRAND_TEAL, p)
}
// Teal → Dark Blue (0.5 → 1)
const p = (t - 0.5) * 2
return lerpColor(BRAND_TEAL, BRAND_DARK_BLUE, p)
}
function lerpColor(a: string, b: string, t: number): string {
const ar = parseInt(a.slice(1, 3), 16)
const ag = parseInt(a.slice(3, 5), 16)
const ab = parseInt(a.slice(5, 7), 16)
const br = parseInt(b.slice(1, 3), 16)
const bg = parseInt(b.slice(3, 5), 16)
const bb = parseInt(b.slice(5, 7), 16)
const r = Math.round(ar + (br - ar) * t)
const g = Math.round(ag + (bg - ag) * t)
const bl = Math.round(ab + (bb - ab) * t)
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`
}
/**
* Helper: get color for a status value from STATUS_COLORS
* Falls back to a neutral gray
*/
export function getStatusColor(status: string): string {
return TREMOR_STATUS_COLORS[status] || 'gray'
}
/**
* Helper: format a status value for display
*/
export function formatStatus(status: string): string {
return STATUS_LABELS[status] || status.charAt(0) + status.slice(1).toLowerCase().replace(/_/g, ' ')
}

View File

@@ -1,15 +1,6 @@
'use client' 'use client'
import { import { BarChart } from '@tremor/react'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface CriteriaScoreData { interface CriteriaScoreData {
@@ -23,31 +14,24 @@ interface CriteriaScoresProps {
data: CriteriaScoreData[] data: CriteriaScoreData[]
} }
// Color scale from red to green based on score
const getScoreColor = (score: number): string => {
if (score >= 8) return '#0bd90f' // Excellent - green
if (score >= 6) return '#82ca9d' // Good - light green
if (score >= 4) return '#ffc658' // Average - yellow
if (score >= 2) return '#ff7300' // Poor - orange
return '#de0f1e' // Very poor - red
}
export function CriteriaScoresChart({ data }: CriteriaScoresProps) { export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
const formattedData = data.map((d) => ({ if (!data?.length) return null
...d,
displayName:
d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name,
}))
const overallAverage = const overallAverage =
data.length > 0 data.length > 0
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length ? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
: 0 : 0
const chartData = data.map((d) => ({
criterion:
d.name.length > 40 ? d.name.substring(0, 40) + '...' : d.name,
'Avg Score': parseFloat(d.averageScore.toFixed(2)),
}))
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<span>Score by Evaluation Criteria</span> <span>Score by Evaluation Criteria</span>
<span className="text-sm font-normal text-muted-foreground"> <span className="text-sm font-normal text-muted-foreground">
Overall Avg: {overallAverage.toFixed(2)} Overall Avg: {overallAverage.toFixed(2)}
@@ -55,51 +39,17 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={formattedData} data={chartData}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }} index="criterion"
> categories={['Avg Score']}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={['indigo']}
<XAxis maxValue={10}
dataKey="displayName" layout="vertical"
tick={{ fontSize: 11 }} yAxisWidth={160}
angle={-45} showLegend={false}
textAnchor="end" className="h-[300px]"
interval={0}
height={60}
/> />
<YAxis domain={[0, 10]} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [
(value ?? 0).toFixed(2),
'Average Score',
]}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as CriteriaScoreData
return `${item.name} (${item.count} ratings)`
}
return ''
}}
/>
<Bar dataKey="averageScore" radius={[4, 4, 0, 0]}>
{formattedData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getScoreColor(entry.averageScore)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,15 +1,6 @@
'use client' 'use client'
import { import { BarChart } from '@tremor/react'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface StageComparison { interface StageComparison {
@@ -26,128 +17,114 @@ interface CrossStageComparisonProps {
data: StageComparison[] data: StageComparison[]
} }
const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f'] export function CrossStageComparisonChart({
data,
}: CrossStageComparisonProps) {
if (!data?.length) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">No comparison data available</p>
</CardContent>
</Card>
)
}
export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) { const baseData = data.map((round) => ({
// Prepare comparison data name: round.roundName,
const comparisonData = data.map((stage, i) => ({ Projects: round.projectCount,
name: stage.roundName.length > 20 ? stage.roundName.slice(0, 20) + '...' : stage.roundName, Evaluations: round.evaluationCount,
projects: stage.projectCount, 'Completion Rate': round.completionRate,
evaluations: stage.evaluationCount, 'Avg Score': round.averageScore
completionRate: stage.completionRate, ? parseFloat(round.averageScore.toFixed(2))
avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0, : 0,
color: STAGE_COLORS[i % STAGE_COLORS.length],
})) }))
return ( return (
<div className="space-y-6">
{/* Metrics Comparison */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Stage Metrics Comparison</CardTitle> <CardTitle>Round Metrics Comparison</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[350px]"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<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> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Completion Rate by Stage</CardTitle> <CardTitle className="text-sm font-medium">Projects</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="pt-0">
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={comparisonData} data={baseData}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }} index="name"
> categories={['Projects']}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={['blue']}
<XAxis showLegend={false}
dataKey="name" yAxisWidth={40}
angle={-25} className="h-[200px]"
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> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Average Score by Stage</CardTitle> <CardTitle className="text-sm font-medium">
Evaluations
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="pt-0">
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={comparisonData} data={baseData}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }} index="name"
> categories={['Evaluations']}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={['violet']}
<XAxis showLegend={false}
dataKey="name" yAxisWidth={40}
angle={-25} className="h-[200px]"
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
/> />
<YAxis domain={[0, 10]} /> </CardContent>
<Tooltip </Card>
contentStyle={{
backgroundColor: 'hsl(var(--card))', <Card>
border: '1px solid hsl(var(--border))', <CardHeader className="pb-2">
borderRadius: '6px', <CardTitle className="text-sm font-medium">
}} Completion Rate
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Completion Rate']}
colors={['emerald']}
showLegend={false}
maxValue={100}
yAxisWidth={40}
valueFormatter={(v) => `${v}%`}
className="h-[200px]"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Average Score
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Avg Score']}
colors={['amber']}
showLegend={false}
maxValue={10}
yAxisWidth={40}
className="h-[200px]"
/> />
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </CardContent>
</Card>
) )
} }

View File

@@ -1,18 +1,6 @@
'use client' 'use client'
import { import { BarChart } from '@tremor/react'
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
} from 'recharts'
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'
@@ -28,12 +16,6 @@ interface DiversityMetricsProps {
data: DiversityData data: DiversityData
} }
const PIE_COLORS = [
'#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f',
'#3be31e', '#c9c052', '#e6382f', '#ed6141', '#0bd90f',
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1',
]
/** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */ /** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */
function getCountryName(code: string): string { function getCountryName(code: string): string {
if (code === 'Others') return 'Others' if (code === 'Others') return 'Others'
@@ -54,35 +36,8 @@ function formatLabel(value: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase()) .replace(/\b\w/g, (c) => c.toUpperCase())
} }
/** Custom tooltip for the pie chart */
function CountryTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { country: string; count: number; percentage: number } }> }) {
if (!active || !payload?.length) return null
const d = payload[0].payload
return (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{getCountryName(d.country)}</p>
<p className="text-muted-foreground">{d.count} projects ({d.percentage.toFixed(1)}%)</p>
</div>
)
}
/** Custom tooltip for bar charts */
function BarTooltip({ active, payload, labelFormatter }: { active?: boolean; payload?: Array<{ value: number }>; label?: string; labelFormatter: (val: string) => string }) {
if (!active || !payload?.length) return null
const entry = payload[0]
const rawPayload = entry as unknown as { payload: Record<string, unknown> }
const dataPoint = rawPayload.payload
const rawLabel = (dataPoint.category || dataPoint.issue || '') as string
return (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{labelFormatter(rawLabel)}</p>
<p className="text-muted-foreground">{entry.value} projects</p>
</div>
)
}
export function DiversityMetricsChart({ data }: DiversityMetricsProps) { export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
if (data.total === 0) { if (!data || data.total === 0) {
return ( return (
<Card> <Card>
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">
@@ -92,125 +47,117 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
) )
} }
// Top countries for pie chart (max 10, others grouped) // Top countries — horizontal bar chart for readability
const topCountries = data.byCountry.slice(0, 10) const countryBarData = (data.byCountry || []).slice(0, 15).map((c) => ({
const otherCountries = data.byCountry.slice(10) country: getCountryName(c.country),
const countryPieData = otherCountries.length > 0 Projects: c.count,
? [...topCountries, {
country: 'Others',
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
percentage: otherCountries.reduce((sum, c) => sum + c.percentage, 0),
}]
: topCountries
// Pre-format category and ocean issue data for display
const formattedCategories = data.byCategory.slice(0, 10).map((c) => ({
...c,
category: formatLabel(c.category),
})) }))
const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({ const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({
...o, category: formatLabel(c.category),
Projects: c.count,
}))
const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
issue: formatLabel(o.issue), issue: formatLabel(o.issue),
Projects: o.count,
})) }))
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Summary */} {/* Summary stats row */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="p-4">
<div className="text-2xl font-bold">{data.total}</div> <p className="text-2xl font-bold tabular-nums">{data.total}</p>
<p className="text-sm text-muted-foreground">Total Projects</p> <p className="text-xs text-muted-foreground">Total Projects</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="p-4">
<div className="text-2xl font-bold">{data.byCountry.length}</div> <p className="text-2xl font-bold tabular-nums">{(data.byCountry || []).length}</p>
<p className="text-sm text-muted-foreground">Countries Represented</p> <p className="text-xs text-muted-foreground">Countries</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="p-4">
<div className="text-2xl font-bold">{data.byCategory.length}</div> <p className="text-2xl font-bold tabular-nums">{(data.byCategory || []).length}</p>
<p className="text-sm text-muted-foreground">Categories</p> <p className="text-xs text-muted-foreground">Categories</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="p-4">
<div className="text-2xl font-bold">{data.byTag.length}</div> <p className="text-2xl font-bold tabular-nums">{(data.byOceanIssue || []).length}</p>
<p className="text-sm text-muted-foreground">Unique Tags</p> <p className="text-xs text-muted-foreground">Ocean Issues</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
{/* Country Distribution */} {/* Country Distribution — horizontal bars */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Geographic Distribution</CardTitle> <CardTitle className="text-base">Geographic Distribution</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[400px]"> {countryBarData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%"> <BarChart
<PieChart> data={countryBarData}
<Pie index="country"
data={countryPieData} categories={['Projects']}
cx="50%" colors={['cyan']}
cy="50%" showLegend={false}
innerRadius={60} layout="horizontal"
outerRadius={120} yAxisWidth={120}
paddingAngle={2} className="h-[360px]"
dataKey="count"
nameKey="country"
label={((props: unknown) => {
const p = props as { country: string; percentage: number }
return `${getCountryName(p.country)} (${p.percentage.toFixed(0)}%)`
}) as unknown as boolean}
fontSize={13}
>
{countryPieData.map((_, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip content={<CountryTooltip />} />
<Legend
formatter={(value: string) => getCountryName(value)}
wrapperStyle={{ fontSize: '13px' }}
/> />
</PieChart> ) : (
</ResponsiveContainer> <p className="text-muted-foreground text-center py-8">No geographic data</p>
</div> )}
</CardContent> </CardContent>
</Card> </Card>
{/* Category Distribution */} {/* Competition Categories — horizontal bars */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Competition Categories</CardTitle> <CardTitle className="text-base">Competition Categories</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{formattedCategories.length > 0 ? ( {categoryData.length > 0 ? (
<div className="h-[400px]"> categoryData.length <= 4 ? (
<ResponsiveContainer width="100%" height="100%"> /* Clean stacked bars for few categories */
<BarChart <div className="space-y-4 pt-2">
data={formattedCategories} {categoryData.map((c) => {
layout="vertical" const maxCount = Math.max(...categoryData.map((d) => d.Projects))
margin={{ top: 5, right: 30, bottom: 5, left: 120 }} const pct = maxCount > 0 ? (c.Projects / maxCount) * 100 : 0
> return (
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <div key={c.category} className="space-y-1.5">
<XAxis type="number" tick={{ fontSize: 13 }} /> <div className="flex items-center justify-between text-sm">
<YAxis <span className="font-medium">{c.category}</span>
type="category" <span className="tabular-nums text-muted-foreground">{c.Projects}</span>
dataKey="category"
width={110}
tick={{ fontSize: 13 }}
/>
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div> </div>
<div className="h-3 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-[#053d57] transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
) : (
<BarChart
data={categoryData}
index="category"
categories={['Projects']}
colors={['indigo']}
layout="horizontal"
yAxisWidth={140}
showLegend={false}
className="h-[280px]"
/>
)
) : ( ) : (
<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>
)} )}
@@ -218,56 +165,43 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
</Card> </Card>
</div> </div>
{/* Ocean Issues */} {/* Ocean Issues — horizontal bars for readability */}
{formattedOceanIssues.length > 0 && ( {oceanIssueData.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Ocean Issues Addressed</CardTitle> <CardTitle className="text-base">Ocean Issues Addressed</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={formattedOceanIssues} data={oceanIssueData}
margin={{ top: 20, right: 30, bottom: 80, left: 20 }} index="issue"
> categories={['Projects']}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={['blue']}
<XAxis showLegend={false}
dataKey="issue" layout="horizontal"
angle={-35} yAxisWidth={200}
textAnchor="end" className="h-[400px]"
height={100}
tick={{ fontSize: 12 }}
interval={0}
/> />
<YAxis tick={{ fontSize: 13 }} />
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Tags Cloud */} {/* Tags — clean pill cloud */}
{data.byTag.length > 0 && ( {(data.byTag || []).length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Project Tags</CardTitle> <CardTitle className="text-base">Project Tags</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{data.byTag.slice(0, 30).map((tag) => ( {(data.byTag || []).slice(0, 30).map((tag) => (
<Badge <Badge
key={tag.tag} key={tag.tag}
variant="secondary" variant="outline"
className="text-sm" className="px-3 py-1 text-sm font-normal"
style={{
fontSize: `${Math.max(0.75, Math.min(1.4, 0.75 + tag.percentage / 20))}rem`,
}}
> >
{tag.tag} ({tag.count}) {tag.tag}
<span className="ml-1.5 text-muted-foreground tabular-nums">({tag.count})</span>
</Badge> </Badge>
))} ))}
</div> </div>

View File

@@ -1,18 +1,6 @@
'use client' 'use client'
import { import { AreaChart } from '@tremor/react'
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Area,
ComposedChart,
Bar,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface TimelineDataPoint { interface TimelineDataPoint {
@@ -26,18 +14,20 @@ interface EvaluationTimelineProps {
} }
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) { export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
// Format date for display 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 chartData = data.map((d) => ({
date: new Date(d.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}),
Cumulative: d.cumulative,
Daily: d.daily,
}))
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -49,53 +39,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]"> <AreaChart
<ResponsiveContainer width="100%" height="100%"> data={chartData}
<ComposedChart index="date"
data={formattedData} categories={['Cumulative', 'Daily']}
margin={{ top: 20, right: 30, bottom: 20, left: 20 }} colors={['indigo', 'amber']}
> curveType="monotone"
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> showGradient={true}
<XAxis yAxisWidth={50}
dataKey="dateFormatted" className="h-[300px]"
tick={{ fontSize: 12 }}
interval="preserveStartEnd"
/> />
<YAxis yAxisId="left" orientation="left" stroke="#8884d8" />
<YAxis yAxisId="right" orientation="right" stroke="#82ca9d" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined, name: string | undefined) => [
value ?? 0,
(name ?? '') === 'daily' ? 'Daily' : 'Cumulative',
]}
labelFormatter={(label) => `Date: ${label}`}
/>
<Legend />
<Bar
yAxisId="left"
dataKey="daily"
name="Daily Evaluations"
fill="#8884d8"
radius={[4, 4, 0, 0]}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="cumulative"
name="Cumulative Total"
stroke="#82ca9d"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 6 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -10,3 +10,4 @@ export { GeographicSummaryCard } from './geographic-summary-card'
export { CrossStageComparisonChart } from './cross-round-comparison' export { CrossStageComparisonChart } from './cross-round-comparison'
export { JurorConsistencyChart } from './juror-consistency' export { JurorConsistencyChart } from './juror-consistency'
export { DiversityMetricsChart } from './diversity-metrics' export { DiversityMetricsChart } from './diversity-metrics'
export { JurorScoreHeatmap } from './juror-score-heatmap'

View File

@@ -1,15 +1,5 @@
'use client' 'use client'
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
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 {
@@ -21,11 +11,11 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { scoreGradient } from './chart-theme'
interface JurorMetric { interface JurorMetric {
userId: string userId: string
name: string name: string
email: string
evaluationCount: number evaluationCount: number
averageScore: number averageScore: number
stddev: number stddev: number
@@ -40,28 +30,49 @@ interface JurorConsistencyProps {
} }
} }
function ScoreDot({ score, maxScore = 10 }: { score: number; maxScore?: number }) {
const pct = ((score / maxScore) * 100).toFixed(1)
return (
<div className="flex items-center gap-2 w-full min-w-[120px]">
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${pct}%`,
backgroundColor: scoreGradient(score),
}}
/>
</div>
<span className="text-xs tabular-nums font-medium w-8 text-right">{score.toFixed(1)}</span>
</div>
)
}
export function JurorConsistencyChart({ data }: JurorConsistencyProps) { export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
const scatterData = data.jurors.map((j) => ({ if (!data?.jurors?.length) {
name: j.name, return (
avgScore: parseFloat(j.averageScore.toFixed(2)), <Card>
stddev: parseFloat(j.stddev.toFixed(2)), <CardContent className="flex items-center justify-center py-12">
evaluations: j.evaluationCount, <p className="text-muted-foreground">No juror consistency data available</p>
isOutlier: j.isOutlier, </CardContent>
})) </Card>
)
}
const outlierCount = data.jurors.filter((j) => j.isOutlier).length const outlierCount = data.jurors.filter((j) => j.isOutlier).length
const sorted = [...data.jurors].sort((a, b) => b.averageScore - a.averageScore)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Scatter: Average Score vs Standard Deviation */} {/* Juror Scoring Patterns — bar-based visual instead of scatter */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between flex-wrap gap-2">
<span>Juror Scoring Patterns</span> <span className="text-base">Juror Scoring Patterns</span>
<span className="text-sm font-normal text-muted-foreground"> <span className="text-sm font-normal text-muted-foreground flex items-center gap-2">
Overall Avg: {data.overallAverage.toFixed(2)} Overall Avg: {data.overallAverage.toFixed(2)}
{outlierCount > 0 && ( {outlierCount > 0 && (
<Badge variant="destructive" className="ml-2"> <Badge variant="destructive">
{outlierCount} outlier{outlierCount > 1 ? 's' : ''} {outlierCount} outlier{outlierCount > 1 ? 's' : ''}
</Badge> </Badge>
)} )}
@@ -69,51 +80,31 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[400px]"> <div className="space-y-2">
<ResponsiveContainer width="100%" height="100%"> {sorted.map((juror) => (
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}> <div
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> key={juror.userId}
<XAxis className={`flex items-center gap-3 rounded-md px-3 py-2 ${juror.isOutlier ? 'bg-destructive/5 border border-destructive/20' : 'hover:bg-muted/50'}`}
type="number" >
dataKey="avgScore" <div className="w-36 shrink-0 truncate">
name="Average Score" <span className="text-sm font-medium">{juror.name}</span>
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> </div>
<p className="text-xs text-muted-foreground mt-2 text-center"> <div className="flex-1">
Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean). <ScoreDot score={juror.averageScore} />
</div>
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
<span className="tabular-nums">&sigma; {juror.stddev.toFixed(1)}</span>
<span className="tabular-nums">{juror.evaluationCount} eval{juror.evaluationCount !== 1 ? 's' : ''}</span>
</div>
{juror.isOutlier && (
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
)}
</div>
))}
</div>
{/* Overall average line */}
<p className="text-xs text-muted-foreground mt-4 text-center">
Bars show average score per juror. &sigma; = standard deviation. Outliers deviate 2+ points from the overall mean.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -121,9 +112,11 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
{/* Juror details table */} {/* Juror details table */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Juror Consistency Details</CardTitle> <CardTitle className="text-base">Juror Consistency Details</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Desktop table */}
<div className="hidden md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -131,24 +124,28 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
<TableHead className="text-right">Evaluations</TableHead> <TableHead className="text-right">Evaluations</TableHead>
<TableHead className="text-right">Avg Score</TableHead> <TableHead className="text-right">Avg Score</TableHead>
<TableHead className="text-right">Std Dev</TableHead> <TableHead className="text-right">Std Dev</TableHead>
<TableHead className="text-right">Deviation from Mean</TableHead> <TableHead className="text-right">Deviation</TableHead>
<TableHead className="text-center">Status</TableHead> <TableHead className="text-center">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.jurors.map((juror) => ( {sorted.map((juror) => (
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}> <TableRow
<TableCell> key={juror.userId}
<div> className={juror.isOutlier ? 'bg-destructive/5' : ''}
<p className="font-medium">{juror.name}</p> >
<p className="text-xs text-muted-foreground">{juror.email}</p> <TableCell className="font-medium">{juror.name}</TableCell>
</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"> <TableCell className="text-right tabular-nums">
{juror.deviationFromOverall.toFixed(2)} {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 >= 0 ? '+' : ''}{juror.deviationFromOverall.toFixed(2)}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{juror.isOutlier ? ( {juror.isOutlier ? (
@@ -164,6 +161,43 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
{/* Mobile card stack */}
<div className="space-y-2 md:hidden">
{sorted.map((juror) => (
<div
key={juror.userId}
className={`rounded-md border p-3 space-y-1 ${juror.isOutlier ? 'bg-destructive/5 border-destructive/20' : ''}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{juror.name}</span>
{juror.isOutlier ? (
<Badge variant="destructive" className="gap-1 text-[10px]">
<AlertTriangle className="h-3 w-3" />
Outlier
</Badge>
) : (
<Badge variant="secondary" className="text-[10px]">Normal</Badge>
)}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<p className="text-muted-foreground">Avg Score</p>
<p className="font-medium tabular-nums">{juror.averageScore.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Std Dev</p>
<p className="font-medium tabular-nums">{juror.stddev.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Evals</p>
<p className="font-medium tabular-nums">{juror.evaluationCount}</p>
</div>
</div>
</div>
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -0,0 +1,240 @@
'use client'
import { Fragment, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { scoreGradient } from './chart-theme'
interface JurorScoreHeatmapProps {
jurors: { id: string; name: string }[]
projects: { id: string; title: string }[]
cells: { jurorId: string; projectId: string; score: number | null }[]
truncated?: boolean
totalProjects?: number
}
function getScoreColor(score: number | null): string {
if (score === null) return 'transparent'
return scoreGradient(score)
}
function getTextColor(score: number | null): string {
if (score === null) return 'inherit'
return score >= 6 ? '#ffffff' : '#1a1a1a'
}
function ScoreBadge({ score }: { score: number }) {
return (
<span
className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
style={{
backgroundColor: getScoreColor(score),
color: getTextColor(score),
}}
>
{score.toFixed(1)}
</span>
)
}
function JurorSummaryRow({
juror,
scores,
averageScore,
projectCount,
isExpanded,
onToggle,
projects,
}: {
juror: { id: string; name: string }
scores: { projectId: string; score: number | null }[]
averageScore: number | null
projectCount: number
isExpanded: boolean
onToggle: () => void
projects: { id: string; title: string }[]
}) {
const scored = scores.filter((s) => s.score !== null)
const unscored = projectCount - scored.length
return (
<>
<tr
className="border-b cursor-pointer transition-colors hover:bg-muted/50"
onClick={onToggle}
>
<td className="py-3 px-4 font-medium text-sm whitespace-nowrap">
<div className="flex items-center gap-2">
<span className={`inline-flex h-5 w-5 items-center justify-center rounded text-[10px] font-bold transition-transform ${isExpanded ? 'rotate-90' : ''}`}>
</span>
{juror.name}
</div>
</td>
<td className="py-3 px-4 text-center tabular-nums text-sm">
{scored.length}
<span className="text-muted-foreground">/{projectCount}</span>
</td>
<td className="py-3 px-4 text-center">
{averageScore !== null ? (
<ScoreBadge score={averageScore} />
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="py-3 px-4">
{/* Mini score bar */}
<div className="flex items-center gap-0.5">
{scored
.sort((a, b) => (a.score ?? 0) - (b.score ?? 0))
.map((s, i) => (
<div
key={i}
className="h-4 w-1.5 rounded-full"
style={{ backgroundColor: getScoreColor(s.score) }}
title={`${s.score?.toFixed(1)}`}
/>
))}
{unscored > 0 &&
Array.from({ length: Math.min(unscored, 10) }).map((_, i) => (
<div
key={`empty-${i}`}
className="h-4 w-1.5 rounded-full bg-muted"
/>
))}
</div>
</td>
</tr>
{isExpanded && (
<tr className="border-b bg-muted/30">
<td colSpan={4} className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{projects.map((p) => {
const cell = scores.find((s) => s.projectId === p.id)
const score = cell?.score ?? null
return (
<div
key={p.id}
className="flex items-center gap-2 rounded-md border bg-background px-2.5 py-1.5"
>
{score !== null ? (
<ScoreBadge score={score} />
) : (
<span className="inline-flex items-center justify-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground min-w-[36px]">
</span>
)}
<span className="text-xs truncate" title={p.title}>
{p.title}
</span>
</div>
)
})}
</div>
</td>
</tr>
)}
</>
)
}
export function JurorScoreHeatmap({
jurors,
projects,
cells,
truncated,
totalProjects,
}: JurorScoreHeatmapProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const cellMap = new Map<string, number | null>()
for (const c of cells) {
cellMap.set(`${c.jurorId}:${c.projectId}`, c.score)
}
if (jurors.length === 0 || projects.length === 0) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No score data available for heatmap</p>
</CardContent>
</Card>
)
}
// Compute per-juror data
const jurorData = jurors.map((j) => {
const scores = projects.map((p) => ({
projectId: p.id,
score: cellMap.get(`${j.id}:${p.id}`) ?? null,
}))
const scored = scores.filter((s) => s.score !== null)
const avg = scored.length > 0
? scored.reduce((sum, s) => sum + (s.score ?? 0), 0) / scored.length
: null
return { juror: j, scores, averageScore: avg ? parseFloat(avg.toFixed(1)) : null, scoredCount: scored.length }
})
// Sort: jurors with most evaluations first
jurorData.sort((a, b) => b.scoredCount - a.scoredCount)
// Color legend
const legendScores = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">Score Heatmap</CardTitle>
<CardDescription>
{jurors.length} juror{jurors.length !== 1 ? 's' : ''} &middot; {projects.length} project{projects.length !== 1 ? 's' : ''}
{truncated && totalProjects ? ` (top ${projects.length} of ${totalProjects})` : ''}
</CardDescription>
</div>
{/* Color legend */}
<div className="hidden sm:flex items-center gap-1 shrink-0">
<span className="text-[10px] text-muted-foreground mr-1">Low</span>
{legendScores.map((s) => (
<div
key={s}
className="h-4 w-4 rounded-sm"
style={{ backgroundColor: getScoreColor(s) }}
title={s.toString()}
/>
))}
<span className="text-[10px] text-muted-foreground ml-1">High</span>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="text-left py-2 px-4 font-medium">Juror</th>
<th className="text-center py-2 px-4 font-medium whitespace-nowrap">Reviewed</th>
<th className="text-center py-2 px-4 font-medium">Avg</th>
<th className="text-left py-2 px-4 font-medium">Score Distribution</th>
</tr>
</thead>
<tbody>
{jurorData.map(({ juror, scores, averageScore }) => (
<JurorSummaryRow
key={juror.id}
juror={juror}
scores={scores}
averageScore={averageScore}
projectCount={projects.length}
isExpanded={expandedId === juror.id}
onToggle={() => setExpandedId(expandedId === juror.id ? null : juror.id)}
projects={projects}
/>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,15 +1,6 @@
'use client' 'use client'
import { import { BarChart } from '@tremor/react'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface JurorWorkloadData { interface JurorWorkloadData {
@@ -25,17 +16,23 @@ interface JurorWorkloadProps {
} }
export function JurorWorkloadChart({ data }: JurorWorkloadProps) { export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
// Truncate names for display if (!data?.length) return null
const formattedData = data.map((d) => ({
...d,
displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name,
}))
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0) const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0) const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
const overallRate = const overallRate =
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0 totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0
const sortedData = [...data].sort(
(a, b) => b.completionRate - a.completionRate,
)
const chartData = sortedData.map((d) => ({
juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
Completed: d.completed,
Remaining: d.assigned - d.completed,
}))
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -47,55 +44,17 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={formattedData} data={chartData}
layout="vertical" index="juror"
margin={{ top: 20, right: 30, bottom: 20, left: 100 }} categories={['Completed', 'Remaining']}
> colors={['blue', 'slate']}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> layout="horizontal"
<XAxis type="number" /> stack={true}
<YAxis yAxisWidth={160}
dataKey="displayName" className={`h-[${Math.max(300, data.length * 35)}px]`}
type="category" style={{ height: `${Math.max(300, data.length * 35)}px` }}
width={90}
tick={{ fontSize: 12 }}
/> />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined, name: string | undefined) => [
value ?? 0,
(name ?? '') === 'assigned' ? 'Assigned' : 'Completed',
]}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as JurorWorkloadData
return `${item.name} (${item.completionRate}% complete)`
}
return ''
}}
/>
<Legend />
<Bar
dataKey="assigned"
name="Assigned"
fill="#8884d8"
radius={[0, 4, 4, 0]}
/>
<Bar
dataKey="completed"
name="Completed"
fill="#82ca9d"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,16 +1,6 @@
'use client' 'use client'
import { import { BarChart } from '@tremor/react'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
ReferenceLine,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface ProjectRankingData { interface ProjectRankingData {
@@ -27,31 +17,24 @@ interface ProjectRankingsProps {
limit?: number limit?: number
} }
// Generate color based on score (red to green gradient)
const getScoreColor = (score: number): string => {
if (score >= 8) return '#0bd90f' // Excellent - green
if (score >= 6) return '#82ca9d' // Good - light green
if (score >= 4) return '#ffc658' // Average - yellow
if (score >= 2) return '#ff7300' // Poor - orange
return '#de0f1e' // Very poor - red
}
export function ProjectRankingsChart({ export function ProjectRankingsChart({
data, data,
limit = 20, limit = 20,
}: ProjectRankingsProps) { }: ProjectRankingsProps) {
const displayData = data.slice(0, limit).map((d, index) => ({ const scoredData = (data ?? []).filter(
...d, (d): d is ProjectRankingData & { averageScore: number } =>
rank: index + 1, d.averageScore !== null,
displayTitle: )
d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title,
score: d.averageScore || 0,
}))
const averageScore = if (!scoredData.length) return null
data.length > 0
? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length const displayData = scoredData.slice(0, limit)
: 0
const chartData = displayData.map((d) => ({
project:
d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title,
Score: parseFloat(d.averageScore.toFixed(2)),
}))
return ( return (
<Card> <Card>
@@ -59,62 +42,23 @@ export function ProjectRankingsChart({
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span>Project Rankings</span> <span>Project Rankings</span>
<span className="text-sm font-normal text-muted-foreground"> <span className="text-sm font-normal text-muted-foreground">
Top {displayData.length} of {data.length} projects Top {displayData.length} of {scoredData.length} scored projects
</span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[500px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={displayData} data={chartData}
layout="vertical" index="project"
margin={{ top: 20, right: 30, bottom: 20, left: 150 }} categories={['Score']}
> colors={['blue']}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> layout="horizontal"
<XAxis type="number" domain={[0, 10]} /> yAxisWidth={200}
<YAxis maxValue={10}
dataKey="displayTitle" showLegend={false}
type="category" className={`h-[${Math.max(400, displayData.length * 30)}px]`}
width={140} style={{ height: `${Math.max(400, displayData.length * 30)}px` }}
tick={{ fontSize: 11 }}
/> />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [(value ?? 0).toFixed(2), 'Average Score']}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as ProjectRankingData & {
rank: number
}
return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}`
}
return ''
}}
/>
<ReferenceLine
x={averageScore}
stroke="#666"
strokeDasharray="5 5"
label={{
value: `Avg: ${averageScore.toFixed(1)}`,
position: 'top',
fill: '#666',
fontSize: 11,
}}
/>
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
{displayData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getScoreColor(entry.score)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,15 +1,6 @@
'use client' 'use client'
import { import { BarChart } from '@tremor/react'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface ScoreDistributionProps { interface ScoreDistributionProps {
@@ -18,24 +9,18 @@ interface ScoreDistributionProps {
totalScores: number totalScores: number
} }
const COLORS = [
'#de0f1e', // 1 - red (poor)
'#e6382f',
'#ed6141',
'#f38a52',
'#f8b364', // 5 - yellow (average)
'#c9c052',
'#99cc41',
'#6ad82f',
'#3be31e',
'#0bd90f', // 10 - green (excellent)
]
export function ScoreDistributionChart({ export function ScoreDistributionChart({
data, data,
averageScore, averageScore,
totalScores, totalScores,
}: ScoreDistributionProps) { }: ScoreDistributionProps) {
if (!data?.length) return null
const chartData = data.map((d) => ({
score: String(d.score),
Count: d.count,
}))
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -47,45 +32,15 @@ export function ScoreDistributionChart({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={data} data={chartData}
margin={{ top: 20, right: 20, bottom: 20, left: 20 }} index="score"
> categories={['Count']}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={['blue']}
<XAxis yAxisWidth={40}
dataKey="score" showLegend={false}
label={{ className="h-[300px]"
value: 'Score',
position: 'insideBottom',
offset: -10,
}}
/> />
<YAxis
label={{
value: 'Count',
angle: -90,
position: 'insideLeft',
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [value ?? 0, 'Count']}
labelFormatter={(label) => `Score: ${label}`}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,13 +1,8 @@
'use client' 'use client'
import {
PieChart, import { DonutChart } from '@tremor/react'
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatStatus, getStatusColor } from './chart-theme'
interface StatusDataPoint { interface StatusDataPoint {
status: string status: string
@@ -18,68 +13,18 @@ interface StatusBreakdownProps {
data: StatusDataPoint[] data: StatusDataPoint[]
} }
const STATUS_COLORS: Record<string, string> = {
PENDING: '#8884d8',
UNDER_REVIEW: '#82ca9d',
SHORTLISTED: '#ffc658',
SEMIFINALIST: '#ff7300',
FINALIST: '#00C49F',
WINNER: '#0088FE',
ELIMINATED: '#de0f1e',
WITHDRAWN: '#999999',
}
const renderCustomLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
}: {
cx?: number
cy?: number
midAngle?: number
innerRadius?: number
outerRadius?: number
percent?: number
}) => {
if (cx === undefined || cy === undefined || midAngle === undefined ||
innerRadius === undefined || outerRadius === undefined || percent === undefined) {
return null
}
if (percent < 0.05) return null // Don't show labels for small slices
const RADIAN = Math.PI / 180
const radius = innerRadius + (outerRadius - innerRadius) * 0.5
const x = cx + radius * Math.cos(-midAngle * RADIAN)
const y = cy + radius * Math.sin(-midAngle * RADIAN)
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={12}
fontWeight={600}
>
{`${(percent * 100).toFixed(0)}%`}
</text>
)
}
export function StatusBreakdownChart({ data }: StatusBreakdownProps) { export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
if (!data?.length) return null
const total = data.reduce((sum, item) => sum + item.count, 0) const total = data.reduce((sum, item) => sum + item.count, 0)
// Format status for display const chartData = data.map((d) => ({
const formattedData = data.map((d) => ({ name: formatStatus(d.status),
...d, value: d.count,
name: d.status.replace(/_/g, ' '),
color: STATUS_COLORS[d.status] || '#8884d8',
})) }))
const colors = data.map((d) => getStatusColor(d.status))
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -91,40 +36,14 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]"> <DonutChart
<ResponsiveContainer width="100%" height="100%"> data={chartData}
<PieChart> category="value"
<Pie index="name"
data={formattedData} colors={colors}
cx="50%" showLabel={true}
cy="50%" className="h-[300px]"
labelLine={false}
label={renderCustomLabel}
outerRadius={100}
innerRadius={50}
fill="#8884d8"
dataKey="count"
nameKey="name"
>
{formattedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined, name: string | undefined) => [
`${value ?? 0} (${(((value ?? 0) / total) * 100).toFixed(1)}%)`,
name ?? '',
]}
/> />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -169,7 +169,7 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
case 'FILTERING': { case 'FILTERING': {
const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
const total = round.filteringTotal const total = round.projectStates.total || round.filteringTotal
const pct = total > 0 ? Math.round((processed / total) * 100) : 0 const pct = total > 0 ? Math.round((processed / total) * 100) : 0
return ( return (

View File

@@ -133,9 +133,11 @@ function getMetric(round: PipelineRound): string {
case 'SUBMISSION': case 'SUBMISSION':
return `${projectStates.COMPLETED} submitted` return `${projectStates.COMPLETED} submitted`
case 'MENTORING': case 'MENTORING':
return '0 mentored' return `${projectStates.COMPLETED ?? 0} mentored`
case 'LIVE_FINAL': case 'LIVE_FINAL': {
return liveSessionStatus || `${projectStates.total} finalists` const status = liveSessionStatus
return status ? status.charAt(0) + status.slice(1).toLowerCase() : `${projectStates.total} finalists`
}
case 'DELIBERATION': case 'DELIBERATION':
return deliberationCount > 0 return deliberationCount > 0
? `${deliberationCount} sessions` ? `${deliberationCount} sessions`

View File

@@ -0,0 +1,114 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ClipboardCheck, ThumbsUp, ThumbsDown, ExternalLink } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
type RecentEvaluation = {
id: string
globalScore: number | null
binaryDecision: boolean | null
submittedAt: Date | string | null
feedbackText: string | null
assignment: {
project: { id: string; title: string }
round: { id: string; name: string }
user: { id: string; name: string | null; email: string }
}
}
export function RecentEvaluations({ evaluations }: { evaluations: RecentEvaluation[] }) {
if (!evaluations || evaluations.length === 0) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="h-4 w-4" />
Recent Evaluations
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
No evaluations submitted yet
</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="h-4 w-4" />
Recent Evaluations
</CardTitle>
<CardDescription>Latest jury reviews as they come in</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{evaluations.map((ev) => (
<Link
key={ev.id}
href={`/admin/projects/${ev.assignment.project.id}`}
className="block group"
>
<div className="flex items-start gap-3 p-2.5 rounded-lg border hover:bg-muted/50 transition-colors">
{/* Score indicator */}
<div className="flex flex-col items-center gap-0.5 shrink-0 pt-0.5">
{ev.globalScore !== null ? (
<span className="text-lg font-bold tabular-nums leading-none">
{ev.globalScore}
</span>
) : (
<span className="text-lg font-bold text-muted-foreground leading-none">-</span>
)}
<span className="text-[10px] text-muted-foreground">/10</span>
</div>
{/* Details */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate flex-1">
{ev.assignment.project.title}
</p>
<ExternalLink className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{ev.assignment.user.name || ev.assignment.user.email}</span>
<span className="shrink-0">
{ev.submittedAt
? formatDistanceToNow(new Date(ev.submittedAt), { addSuffix: true })
: ''}
</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] h-5">
{ev.assignment.round.name}
</Badge>
{ev.binaryDecision !== null && (
ev.binaryDecision ? (
<span className="flex items-center gap-0.5 text-xs text-emerald-600">
<ThumbsUp className="h-3 w-3" /> Yes
</span>
) : (
<span className="flex items-center gap-0.5 text-xs text-red-500">
<ThumbsDown className="h-3 w-3" /> No
</span>
)
)}
</div>
{ev.feedbackText && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{ev.feedbackText}
</p>
)}
</div>
</div>
</Link>
))}
</CardContent>
</Card>
)
}

View File

@@ -29,12 +29,6 @@ type SmartActionsProps = {
actions: DashboardAction[] actions: DashboardAction[]
} }
const severityOrder: Record<DashboardAction['severity'], number> = {
critical: 0,
warning: 1,
info: 2,
}
const severityConfig = { const severityConfig = {
critical: { critical: {
icon: AlertTriangle, icon: AlertTriangle,
@@ -57,10 +51,6 @@ const severityConfig = {
} }
export function SmartActions({ actions }: SmartActionsProps) { export function SmartActions({ actions }: SmartActionsProps) {
const sorted = [...actions].sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity]
)
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4"> <CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
@@ -73,7 +63,7 @@ export function SmartActions({ actions }: SmartActionsProps) {
)} )}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{sorted.length === 0 ? ( {actions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
<CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" /> <CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
@@ -84,7 +74,7 @@ export function SmartActions({ actions }: SmartActionsProps) {
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{sorted.map((action) => { {actions.map((action) => {
const config = severityConfig[action.severity] const config = severityConfig[action.severity]
const Icon = config.icon const Icon = config.icon

View File

@@ -39,6 +39,7 @@ export function formatAction(action: string, entityType: string | null): string
DELETE_OWN_ACCOUNT: 'deleted their account', DELETE_OWN_ACCOUNT: 'deleted their account',
EVALUATION_SUBMITTED: 'submitted an evaluation', EVALUATION_SUBMITTED: 'submitted an evaluation',
COI_DECLARED: 'declared a conflict of interest', COI_DECLARED: 'declared a conflict of interest',
COI_NO_CONFLICT: 'confirmed no conflict of interest',
COI_REVIEWED: 'reviewed a COI declaration', COI_REVIEWED: 'reviewed a COI declaration',
REMINDERS_TRIGGERED: 'triggered evaluation reminders', REMINDERS_TRIGGERED: 'triggered evaluation reminders',
DISCUSSION_COMMENT_ADDED: 'added a discussion comment', DISCUSSION_COMMENT_ADDED: 'added a discussion comment',

View File

@@ -21,7 +21,8 @@ import { Badge } from '@/components/ui/badge';
interface Project { interface Project {
id: string; id: string;
title: string; title: string;
category: string; category?: string;
teamName?: string | null;
} }
interface DeliberationRankingFormProps { interface DeliberationRankingFormProps {

View File

@@ -2,40 +2,19 @@
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { FileText, Download, ExternalLink } from 'lucide-react' import { FileText } from 'lucide-react'
import { toast } from 'sonner' import { FileViewer } from '@/components/shared/file-viewer'
interface MultiWindowDocViewerProps { interface MultiWindowDocViewerProps {
roundId: string roundId: string
projectId: string projectId: string
} }
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return '🖼️'
if (mimeType.startsWith('video/')) return '🎥'
if (mimeType.includes('pdf')) return '📄'
if (mimeType.includes('word') || mimeType.includes('document')) return '📝'
if (mimeType.includes('sheet') || mimeType.includes('excel')) return '📊'
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📊'
return '📎'
}
export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewerProps) { export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewerProps) {
const { data: windows, isLoading } = trpc.round.getVisibleWindows.useQuery( const { data: files, isLoading } = trpc.file.listByProject.useQuery(
{ roundId }, { projectId },
{ enabled: !!roundId } { enabled: !!projectId }
) )
if (isLoading) { if (isLoading) {
@@ -51,95 +30,97 @@ export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewe
) )
} }
if (!windows || windows.length === 0) { if (!files || files.length === 0) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Documents</CardTitle> <CardTitle>Documents</CardTitle>
<CardDescription>Submission windows and uploaded files</CardDescription> <CardDescription>Project files and submissions</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" /> <FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No submission windows available</p> <p className="text-sm text-muted-foreground">No files uploaded</p>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
return ( // Group files by round name for the grouped view
<Card> const groupMap: Record<string, {
<CardHeader> roundId: string | null
<CardTitle>Documents</CardTitle> roundName: string
<CardDescription>Files submitted across all windows</CardDescription> sortOrder: number
</CardHeader> files: typeof files
<CardContent> }> = {}
<Tabs defaultValue={windows[0]?.id || ''} className="w-full">
<TabsList className="w-full flex-wrap justify-start h-auto gap-1 bg-transparent p-0 mb-4">
{windows.map((window: any) => (
<TabsTrigger
key={window.id}
value={window.id}
className="data-[state=active]:bg-brand-blue data-[state=active]:text-white px-4 py-2 rounded-md text-sm"
>
{window.name}
{window.files && window.files.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{window.files.length}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{windows.map((window: any) => ( for (const file of files) {
<TabsContent key={window.id} value={window.id} className="mt-0"> const roundName = file.requirement?.round?.name ?? 'General'
{!window.files || window.files.length === 0 ? ( const rId = file.requirement?.round?.id ?? null
<div className="text-center py-8 border border-dashed rounded-lg"> const sortOrder = file.requirement?.round?.sortOrder ?? 999
<FileText className="h-10 w-10 text-muted-foreground/50 mx-auto mb-2" /> if (!groupMap[roundName]) {
<p className="text-sm text-muted-foreground">No files uploaded</p> groupMap[roundName] = { roundId: rId, roundName, sortOrder, files: [] }
</div> }
) : ( groupMap[roundName].files.push(file)
<div className="grid gap-3 sm:grid-cols-2"> }
{window.files.map((file: any) => (
<Card key={file.id} className="overflow-hidden"> const groupedFiles = Object.values(groupMap)
<CardContent className="p-4">
<div className="flex items-start gap-3"> // If only one group, use flat view
<div className="text-2xl">{getFileIcon(file.mimeType || '')}</div> if (groupedFiles.length === 1) {
<div className="flex-1 min-w-0"> const mappedFiles = files.map((f) => ({
<p className="font-medium text-sm truncate" title={file.filename}> id: f.id,
{file.filename} fileType: (f.fileType ?? 'OTHER') as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC',
</p> fileName: f.fileName,
<div className="flex items-center gap-2 mt-1"> mimeType: f.mimeType,
<Badge variant="outline" className="text-xs"> size: f.size,
{file.mimeType?.split('/')[1]?.toUpperCase() || 'FILE'} bucket: f.bucket,
</Badge> objectKey: f.objectKey,
{file.size && ( version: f.version ?? undefined,
<span className="text-xs text-muted-foreground"> requirementId: f.requirementId,
{formatFileSize(file.size)} requirement: f.requirement ? {
</span> id: f.requirement.id,
)} name: f.requirement.name,
</div> description: f.requirement.description,
<div className="flex gap-2 mt-3"> isRequired: f.requirement.isRequired,
<Button size="sm" variant="outline" className="h-7 text-xs"> } : undefined,
<Download className="mr-1 h-3 w-3" /> pageCount: (f as any).pageCount ?? undefined,
Download textPreview: (f as any).textPreview ?? undefined,
</Button> detectedLang: (f as any).detectedLang ?? undefined,
<Button size="sm" variant="ghost" className="h-7 text-xs"> langConfidence: (f as any).langConfidence ?? undefined,
<ExternalLink className="mr-1 h-3 w-3" /> analyzedAt: (f as any).analyzedAt ?? undefined,
Preview }))
</Button>
</div> return <FileViewer files={mappedFiles} />
</div> }
</div>
</CardContent> // Multiple groups — use grouped view
</Card> const mappedGroups = groupedFiles.map((g) => ({
))} roundId: g.roundId,
</div> roundName: g.roundName,
)} sortOrder: g.sortOrder,
</TabsContent> files: g.files.map((f) => ({
))} id: f.id,
</Tabs> fileType: (f.fileType ?? 'OTHER') as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC',
</CardContent> fileName: f.fileName,
</Card> mimeType: f.mimeType,
) size: f.size,
bucket: f.bucket,
objectKey: f.objectKey,
version: f.version ?? undefined,
requirementId: f.requirementId,
requirement: f.requirement ? {
id: f.requirement.id,
name: f.requirement.name,
description: f.requirement.description,
isRequired: f.requirement.isRequired,
} : undefined,
pageCount: (f as any).pageCount ?? undefined,
textPreview: (f as any).textPreview ?? undefined,
detectedLang: (f as any).detectedLang ?? undefined,
langConfidence: (f as any).langConfidence ?? undefined,
analyzedAt: (f as any).analyzedAt ?? undefined,
})),
}))
return <FileViewer groupedFiles={mappedGroups} />
} }

View File

@@ -59,7 +59,6 @@ type NavItem = {
icon: typeof LayoutDashboard icon: typeof LayoutDashboard
activeMatch?: string // pathname must include this to be active activeMatch?: string // pathname must include this to be active
activeExclude?: string // pathname must NOT include this to be active activeExclude?: string // pathname must NOT include this to be active
subItems?: { name: string; href: string }[]
} }
// Main navigation - scoped to selected edition // Main navigation - scoped to selected edition
@@ -227,10 +226,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{navigation.map((item) => { {navigation.map((item) => {
const isActive = const isActive =
pathname === item.href || pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href)) (item.href !== '/admin' && pathname.startsWith(item.href)) ||
const isParentActive = item.subItems (item.href === '/admin/rounds' && pathname.startsWith('/admin/competitions'))
? pathname.startsWith('/admin/competitions')
: false
return ( return (
<div key={item.name}> <div key={item.name}>
<Link <Link
@@ -249,29 +246,6 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
)} /> )} />
{item.name} {item.name}
</Link> </Link>
{item.subItems && isParentActive && (
<div className="ml-7 mt-0.5 space-y-0.5">
{item.subItems.map((sub) => {
const isSubActive = pathname === sub.href ||
(sub.href !== '/admin/competitions' && pathname.startsWith(sub.href))
return (
<Link
key={sub.name}
href={sub.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'block rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
isSubActive
? 'text-brand-blue bg-brand-blue/10'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
{sub.name}
</Link>
)
})}
</div>
)}
</div> </div>
) )
})} })}
@@ -285,12 +259,24 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
Administration Administration
</p> </p>
{dynamicAdminNav.map((item) => { {dynamicAdminNav.map((item) => {
const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
let isActive = pathname.startsWith(item.href) let isActive = pathname.startsWith(item.href)
if (item.activeMatch) { if (item.activeMatch) {
isActive = pathname.includes(item.activeMatch) isActive = pathname.includes(item.activeMatch)
} else if (item.activeExclude && pathname.includes(item.activeExclude)) { } else if (item.activeExclude && pathname.includes(item.activeExclude)) {
isActive = false isActive = false
} }
if (isDisabled) {
return (
<span
key={item.name}
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium opacity-50 pointer-events-none text-muted-foreground"
>
<item.icon className="h-4 w-4 text-muted-foreground" />
{item.name}
</span>
)
}
return ( return (
<Link <Link
key={item.name} key={item.name}

View File

@@ -1,12 +1,41 @@
'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'
import { useEditionContext } from '@/components/observer/observer-edition-context'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface ObserverNavProps { interface ObserverNavProps {
user: RoleNavUser user: RoleNavUser
} }
function EditionSelector() {
const { programs, selectedProgramId, setSelectedProgramId } = useEditionContext()
if (programs.length <= 1) return null
return (
<Select value={selectedProgramId} onValueChange={setSelectedProgramId}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue placeholder="Select edition" />
</SelectTrigger>
<SelectContent>
{programs.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.year ? `${p.year} Edition` : p.name ?? p.id}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function ObserverNav({ user }: ObserverNavProps) { export function ObserverNav({ user }: ObserverNavProps) {
const navigation: NavItem[] = [ const navigation: NavItem[] = [
{ {
@@ -14,6 +43,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',
@@ -27,6 +61,7 @@ export function ObserverNav({ user }: ObserverNavProps) {
roleName="Observer" roleName="Observer"
user={user} user={user}
basePath="/observer" basePath="/observer"
editionSelector={<EditionSelector />}
/> />
) )
} }

View File

@@ -41,13 +41,15 @@ type RoleNavProps = {
basePath: string basePath: string
/** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */ /** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */
statusBadge?: React.ReactNode statusBadge?: React.ReactNode
/** Optional slot rendered in the mobile hamburger menu (between nav links and sign out) and desktop header */
editionSelector?: React.ReactNode
} }
function isNavItemActive(pathname: string, href: string, basePath: string): boolean { function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
return pathname === href || (href !== basePath && pathname.startsWith(href)) return pathname === href || (href !== basePath && pathname.startsWith(href))
} }
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) { export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
const pathname = usePathname() const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession() const { status: sessionStatus } = useSession()
@@ -64,10 +66,10 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
<div className="container-app"> <div className="container-app">
<div className="flex h-16 items-center justify-between"> <div className="flex h-16 items-center justify-between">
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-3"> <Link href={basePath as any} className="flex items-center gap-3">
<Logo showText textSuffix={roleName} /> <Logo showText textSuffix={roleName} />
{statusBadge} {statusBadge}
</div> </Link>
{/* Desktop nav */} {/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1"> <nav className="hidden md:flex items-center gap-1">
@@ -93,6 +95,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
{/* User menu & mobile toggle */} {/* User menu & mobile toggle */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{editionSelector && <div className="hidden md:block">{editionSelector}</div>}
{mounted && ( {mounted && (
<Button <Button
variant="ghost" variant="ghost"
@@ -161,9 +164,15 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
</div> </div>
</div> </div>
{/* Mobile menu */} {/* Mobile menu — animated with CSS grid */}
{isMobileMenuOpen && ( <div
<div className="border-t md:hidden"> className={cn(
'grid md:hidden transition-[grid-template-rows] duration-200 ease-out',
isMobileMenuOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="overflow-hidden">
<div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}>
<nav className="container-app py-4 space-y-1"> <nav className="container-app py-4 space-y-1">
{navigation.map((item) => { {navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath) const isActive = isNavItemActive(pathname, item.href, basePath)
@@ -184,6 +193,11 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
</Link> </Link>
) )
})} })}
{editionSelector && (
<div className="border-t pt-4 mt-4 px-3">
{editionSelector}
</div>
)}
<div className="border-t pt-4 mt-4"> <div className="border-t pt-4 mt-4">
<Button <Button
variant="ghost" variant="ghost"
@@ -196,7 +210,8 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
</div> </div>
</nav> </nav>
</div> </div>
)} </div>
</div>
</header> </header>
) )
} }

View File

@@ -1,18 +1,19 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
CardDescription,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { import {
Table, Table,
TableBody, TableBody,
@@ -21,502 +22,545 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { StatusBadge } from '@/components/shared/status-badge' import { StatusBadge } from '@/components/shared/status-badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card'
import { useEditionContext } from '@/components/observer/observer-edition-context'
import { import {
FolderKanban,
ClipboardList, ClipboardList,
Users,
CheckCircle2,
Eye,
BarChart3, BarChart3,
Search, TrendingUp,
ChevronLeft, Users,
Globe,
ChevronRight, ChevronRight,
Activity,
ChevronDown,
ChevronUp,
ArrowRight,
Lock,
Clock,
CheckCircle,
XCircle,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container'
import { useDebouncedCallback } from 'use-debounce'
const PER_PAGE_OPTIONS = [10, 20, 50] function relativeTime(date: Date | string): string {
const now = Date.now()
const then = new Date(date).getTime()
const diff = Math.floor((now - then) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
function computeAvgScore(scoreDistribution: { label: string; count: number }[]): string {
const midpoints: Record<string, number> = {
'9-10': 9.5,
'7-8': 7.5,
'5-6': 5.5,
'3-4': 3.5,
'1-2': 1.5,
}
let total = 0
let weightedSum = 0
for (const b of scoreDistribution) {
const mid = midpoints[b.label]
if (mid !== undefined) {
weightedSum += mid * b.count
total += b.count
}
}
if (total === 0) return '—'
return (weightedSum / total).toFixed(1)
}
const ACTIVITY_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
ROUND_ACTIVATED: { icon: Clock, color: 'text-emerald-500' },
ROUND_CLOSED: { icon: Lock, color: 'text-slate-500' },
'round.reopened': { icon: Clock, color: 'text-emerald-500' },
'round.closed': { icon: Lock, color: 'text-slate-500' },
EVALUATION_SUBMITTED: { icon: CheckCircle, color: 'text-blue-500' },
ASSIGNMENT_CREATED: { icon: ArrowRight, color: 'text-violet-500' },
PROJECT_ADVANCED: { icon: ArrowRight, color: 'text-teal-500' },
PROJECT_REJECTED: { icon: XCircle, color: 'text-rose-500' },
RESULT_LOCKED: { icon: Lock, color: 'text-amber-500' },
}
function humanizeActivity(item: { eventType: string; actorName?: string | null; details?: Record<string, unknown> | null }): string {
const actor = item.actorName ?? 'System'
const details = item.details ?? {}
const projectName = (details.projectTitle ?? details.projectName ?? '') as string
const roundName = (details.roundName ?? '') as string
switch (item.eventType) {
case 'EVALUATION_SUBMITTED':
return projectName
? `${actor} submitted a review for ${projectName}`
: `${actor} submitted a review`
case 'ROUND_ACTIVATED':
case 'round.reopened':
return roundName ? `${roundName} was opened` : 'A round was opened'
case 'ROUND_CLOSED':
case 'round.closed':
return roundName ? `${roundName} was closed` : 'A round was closed'
case 'ASSIGNMENT_CREATED':
return projectName
? `${projectName} was assigned to a juror`
: 'A project was assigned'
case 'PROJECT_ADVANCED':
return projectName
? `${projectName} advanced${roundName ? ` to ${roundName}` : ''}`
: 'A project advanced'
case 'PROJECT_REJECTED':
return projectName ? `${projectName} was rejected` : 'A project was rejected'
case 'RESULT_LOCKED':
return roundName ? `Results locked for ${roundName}` : 'Results were locked'
default:
return `${actor}: ${item.eventType.replace(/_/g, ' ').toLowerCase()}`
}
}
const STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = {
ROUND_ACTIVE: 'default',
ROUND_CLOSED: 'secondary',
ROUND_DRAFT: 'outline',
ROUND_ARCHIVED: 'secondary',
}
export function ObserverDashboardContent({ userName }: { userName?: string }) { export function ObserverDashboardContent({ userName }: { userName?: string }) {
const [selectedRoundId, setSelectedRoundId] = useState<string>('all') const { programs, selectedProgramId, activeRoundId } = useEditionContext()
const [search, setSearch] = useState('') const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
const [debouncedSearch, setDebouncedSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [page, setPage] = useState(1)
const [perPage, setPerPage] = useState(20)
const debouncedSetSearch = useDebouncedCallback((value: string) => { const roundIdParam = activeRoundId || undefined
setDebouncedSearch(value)
setPage(1)
}, 300)
const handleSearchChange = (value: string) => {
setSearch(value)
debouncedSetSearch(value)
}
const handleRoundChange = (value: string) => {
setSelectedRoundId(value)
setPage(1)
}
const handleStatusChange = (value: string) => {
setStatusFilter(value)
setPage(1)
}
// Fetch programs/rounds for the filter dropdown
const { data: programs } = trpc.program.list.useQuery({})
const rounds = programs?.flatMap((p) =>
(p.rounds ?? []).map((r: { id: string; name: string; status: string }) => ({
id: r.id,
name: r.name,
programName: `${p.year} Edition`,
status: r.status,
}))
) || []
// Fetch dashboard stats
const roundIdParam = selectedRoundId !== 'all' ? selectedRoundId : undefined
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery( const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
{ roundId: roundIdParam } { roundId: roundIdParam },
{ refetchInterval: 30_000 },
) )
// Fetch projects const selectedProgram = programs.find((p) => p.id === selectedProgramId)
const { data: projectsData, isLoading: projectsLoading } = trpc.analytics.getAllProjects.useQuery({ const competitionId = (selectedProgram?.rounds ?? [])[0]?.competitionId as string | undefined
roundId: roundIdParam,
search: debouncedSearch || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
page,
perPage,
})
// Fetch recent rounds for jury completion const { data: roundOverview, isLoading: overviewLoading } = trpc.analytics.getRoundCompletionOverview.useQuery(
const { data: recentRoundsData } = trpc.program.list.useQuery({}) { competitionId: competitionId! },
const recentRounds = recentRoundsData?.flatMap((p) => { enabled: !!competitionId, refetchInterval: 30_000 },
(p.rounds ?? []).map((r: { id: string; name: string; status: string }) => ({ )
...r,
programName: `${p.year} Edition`, const { data: jurorWorkload } = trpc.analytics.getJurorWorkload.useQuery(
})) { programId: selectedProgramId || undefined },
)?.slice(0, 5) || [] { enabled: !!selectedProgramId, refetchInterval: 30_000 },
)
const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery(
{ programId: selectedProgramId },
{ enabled: !!selectedProgramId, refetchInterval: 30_000 },
)
const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
{ perPage: 10 },
{ refetchInterval: 30_000 },
)
const { data: activityFeed } = trpc.analytics.getActivityFeed.useQuery(
{ limit: 10 },
{ refetchInterval: 30_000 },
)
const countryCount = geoData ? geoData.length : 0
const avgScore = stats ? computeAvgScore(stats.scoreDistribution) : '—'
const allJurors = jurorWorkload ?? []
const scoreColors: Record<string, string> = {
'9-10': '#053d57',
'7-8': '#1e7a8a',
'5-6': '#557f8c',
'3-4': '#c4453a',
'1-2': '#de0f1e',
}
const maxScoreCount = stats
? Math.max(...stats.scoreDistribution.map((b) => b.count), 1)
: 1
const recentlyReviewed = (projectsData?.projects ?? []).filter(
(p) => {
const status = p.observerStatus ?? p.status
return status !== 'REJECTED' && status !== 'NOT_REVIEWED' && status !== 'SUBMITTED'
},
)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
Welcome, {userName || 'Observer'}
</p>
</div> </div>
{/* Observer Notice */} {/* Stats Strip */}
<div className="rounded-lg border border-blue-200 bg-blue-50/50 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-100 p-2.5">
<Eye className="h-4 w-4 text-blue-600" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-semibold text-blue-900">Observer Mode</p>
<Badge variant="outline" className="border-blue-300 text-blue-700 text-xs">
Read-Only
</Badge>
</div>
<p className="text-sm text-blue-700">
You have read-only access to view platform statistics and reports.
</p>
</div>
</div>
</div>
{/* Round Filter */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Filter by Round:</label>
<Select value={selectedRoundId} onValueChange={handleRoundChange}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="All Rounds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Rounds</SelectItem>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stats Grid */}
{statsLoading ? ( {statsLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <Card className="p-4">
{[...Array(4)].map((_, i) => ( <Skeleton className="h-10 w-full" />
<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-3 w-24" />
</CardContent>
</Card> </Card>
) : stats ? (
<Card className="p-0 overflow-hidden">
<div className="grid grid-cols-3 md:grid-cols-6 divide-x divide-border">
{[
{ value: stats.projectCount, label: 'Projects' },
{ value: stats.activeRoundName ?? `${stats.activeRoundCount} Active`, label: 'Active Round', isText: !!stats.activeRoundName },
{ value: avgScore, label: 'Avg Score' },
{ value: `${stats.completionRate}%`, label: 'Completion' },
{ value: stats.jurorCount, label: 'Jurors' },
{ value: countryCount, label: 'Countries' },
].map((stat) => (
<div key={stat.label} className="px-4 py-3.5 text-center">
<p className={`font-semibold leading-tight ${
'isText' in stat && stat.isText ? 'text-sm truncate' : 'text-xl tabular-nums'
}`}>{stat.value}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{stat.label}</p>
</div>
))} ))}
</div> </div>
) : stats ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Programs</p>
<p className="text-2xl font-bold mt-1">{stats.programCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<FolderKanban className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card> </Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Projects</p>
<p className="text-2xl font-bold mt-1">{stats.projectCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
<p className="text-2xl font-bold mt-1">{stats.jurorCount}</p>
<p className="text-xs text-muted-foreground mt-1">Active members</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">{stats.submittedEvaluations}</p>
<div className="mt-2">
<Progress value={stats.completionRate} className="h-2" gradient />
<p className="mt-1 text-xs text-muted-foreground">
{stats.completionRate}% completion rate
</p>
</div>
</div>
<div className="rounded-xl bg-brand-teal/10 p-3">
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
) : null} ) : null}
{/* Projects Table */} {/* Pipeline */}
<AnimatedCard index={4}> <AnimatedCard index={6}>
<Card> <Card>
<CardHeader> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2.5"> <CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-blue-500" />
</div>
Competition Pipeline
</CardTitle>
<CardDescription>Round-by-round progression overview</CardDescription>
</CardHeader>
<CardContent>
{overviewLoading || !competitionId ? (
<div className="flex gap-4 overflow-x-auto pb-2">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-32 w-40 shrink-0 rounded-lg" />
))}
</div>
) : roundOverview && roundOverview.rounds.length > 0 ? (
<div className="flex items-stretch gap-0 overflow-x-auto pb-2">
{roundOverview.rounds.map((round, idx) => (
<div key={round.roundName + idx} className="flex items-center">
<Card className="w-44 shrink-0 border shadow-sm">
<CardContent className="p-3 space-y-2">
<p className="text-xs font-semibold leading-tight truncate" title={round.roundName}>
{round.roundName}
</p>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round.roundType.replace(/_/g, ' ')}
</Badge>
<Badge
variant={STATUS_BADGE_VARIANT[round.roundStatus] ?? 'outline'}
className="text-[10px] px-1.5 py-0"
>
{round.roundStatus === 'ROUND_ACTIVE'
? 'Active'
: round.roundStatus === 'ROUND_CLOSED'
? 'Closed'
: round.roundStatus === 'ROUND_DRAFT'
? 'Draft'
: round.roundStatus === 'ROUND_ARCHIVED'
? 'Archived'
: round.roundStatus}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''}
</p>
<div className="space-y-1">
<Progress value={round.completionRate} className="h-1.5" />
<p className="text-[10px] text-muted-foreground tabular-nums">
{round.completionRate}% complete
</p>
</div>
</CardContent>
</Card>
{idx < roundOverview.rounds.length - 1 && (
<div className="h-px w-6 shrink-0 border-t-2 border-brand-teal" />
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No round data available for this competition.</p>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Middle Row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left column: Score Distribution + Recently Reviewed stacked */}
<div className="flex flex-col gap-6">
{/* Score Distribution */}
<AnimatedCard index={7}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<TrendingUp className="h-4 w-4 text-amber-500" />
</div>
Score Distribution
</CardTitle>
</CardHeader>
<CardContent>
{stats ? (
<div className="space-y-1.5">
{stats.scoreDistribution.map((bucket) => (
<div key={bucket.label} className="flex items-center gap-2">
<span className="w-8 text-right text-[11px] font-medium tabular-nums text-muted-foreground">
{bucket.label}
</span>
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 14 }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${maxScoreCount > 0 ? (bucket.count / maxScoreCount) * 100 : 0}%`,
backgroundColor: scoreColors[bucket.label] ?? '#557f8c',
}}
/>
</div>
<span className="w-6 text-right text-[11px] tabular-nums text-muted-foreground">
{bucket.count}
</span>
</div>
))}
</div>
) : (
<div className="space-y-1.5">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Recently Reviewed */}
<AnimatedCard index={10} className="flex-1 flex flex-col">
<Card className="flex-1 flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-emerald-500/10 p-1.5"> <div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-500" /> <ClipboardList className="h-4 w-4 text-emerald-500" />
</div> </div>
All Projects Recently Reviewed
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Latest project reviews</CardDescription>
{projectsData ? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} found` : 'Loading projects...'}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="p-0">
{/* Search & Filter Bar */} {recentlyReviewed.length > 0 ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<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={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="UNDER_REVIEW">Under Review</SelectItem>
<SelectItem value="SHORTLISTED">Shortlisted</SelectItem>
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem>
<SelectItem value="WINNER">Winner</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem>
<SelectItem value="WITHDRAWN">Withdrawn</SelectItem>
</SelectContent>
</Select>
<Select value={String(perPage)} onValueChange={(v) => { setPerPage(Number(v)); setPage(1) }}>
<SelectTrigger className="w-full sm:w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>{n} / page</SelectItem>
))}
</SelectContent>
</Select>
</div>
{projectsLoading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : projectsData && projectsData.projects.length > 0 ? (
<> <>
{/* Desktop Table */}
<div className="hidden md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Title</TableHead> <TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Avg Score</TableHead> <TableHead className="text-right whitespace-nowrap">Score</TableHead>
<TableHead className="text-right">Evaluations</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{projectsData.projects.map((project) => ( {recentlyReviewed.map((project) => (
<TableRow key={project.id}> <TableRow key={project.id}>
<TableCell className="font-medium max-w-[250px] truncate"> <TableCell className="max-w-[140px]">
<Link
href={`/observer/projects/${project.id}` as Route}
className="block truncate text-sm font-medium hover:underline"
title={project.title}
>
{project.title} {project.title}
</TableCell> </Link>
<TableCell className="max-w-[150px] truncate">{project.teamName || '-'}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs whitespace-nowrap">
{project.roundName}
</Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusBadge status={project.status} /> <StatusBadge status={project.observerStatus ?? project.status} size="sm" />
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="text-right tabular-nums text-sm whitespace-nowrap">
{project.averageScore !== null {project.evaluationCount > 0 && project.averageScore !== null
? project.averageScore.toFixed(2) ? project.averageScore.toFixed(1)
: '-'} : ''}
</TableCell>
<TableCell className="text-right tabular-nums">
{project.evaluationCount}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> <div className="border-t px-4 py-3">
<Link
{/* Mobile Cards */} href={"/observer/projects" as Route}
<div className="space-y-3 md:hidden"> className="flex items-center gap-1 text-sm font-medium text-brand-teal hover:underline"
{projectsData.projects.map((project) => (
<Card key={project.id}>
<CardContent className="pt-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm leading-tight">{project.title}</p>
<StatusBadge status={project.status} />
</div>
{project.teamName && (
<p className="text-xs text-muted-foreground">{project.teamName}</p>
)}
<div className="flex items-center justify-between 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(2) : '-'}</span>
<span>{project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Pagination */}
{projectsData.totalPages > 1 && (
<div className="flex items-center justify-between pt-2">
<p className="text-sm text-muted-foreground">
Page {projectsData.page} of {projectsData.totalPages}
</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" /> View All <ChevronRight className="h-4 w-4" />
</Button> </Link>
<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>
)}
</> </>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="space-y-2 p-4">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" /> {[...Array(3)].map((_, i) => (
<p className="mt-2 text-sm text-muted-foreground"> <Skeleton key={i} className="h-10 w-full" />
{debouncedSearch || statusFilter !== 'all'
? 'No projects match your filters'
: 'No projects found'}
</p>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Score Distribution */}
{stats && stats.scoreDistribution.some((b) => b.count > 0) && (
<AnimatedCard index={5}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-amber-500" />
</div>
Score Distribution
</CardTitle>
<CardDescription>Distribution of global scores across evaluations</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(() => {
const maxCount = Math.max(...stats.scoreDistribution.map((b) => b.count), 1)
const colors = ['bg-green-500', 'bg-emerald-400', 'bg-amber-400', 'bg-orange-400', 'bg-red-400']
return stats.scoreDistribution.map((bucket, i) => (
<div key={bucket.label} className="flex items-center gap-3">
<span className="text-sm w-16 text-right tabular-nums">{bucket.label}</span>
<div className="flex-1 h-6 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', colors[i])}
style={{ width: `${maxCount > 0 ? (bucket.count / maxCount) * 100 : 0}%` }}
/>
</div>
<span className="text-sm tabular-nums w-8">{bucket.count}</span>
</div>
))
})()}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Recent Rounds */}
{recentRounds.length > 0 && (
<AnimatedCard index={6}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-violet-500" />
</div>
Recent Rounds
</CardTitle>
<CardDescription>Overview of the latest evaluation rounds</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentRounds.map((round) => (
<div
key={round.id}
className="flex items-center justify-between rounded-lg border p-4 transition-all hover:shadow-sm"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{round.name}</p>
<Badge
variant={
round.status === 'ROUND_ACTIVE'
? 'default'
: round.status === 'ROUND_CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status === 'ROUND_ACTIVE' ? 'Active' : round.status === 'ROUND_CLOSED' ? 'Closed' : round.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{round.programName}
</p>
</div>
<div className="text-right text-sm">
<p>Round details</p>
<p className="text-muted-foreground">
View analytics
</p>
</div>
</div>
))} ))}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
</div>
{/* Juror Workload — scrollable list of all jurors */}
<AnimatedCard index={8}>
<Card className="h-full flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Juror Workload
</CardTitle>
<CardDescription>All jurors by assignment</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
{allJurors.length > 0 ? (
<div className="max-h-[500px] overflow-y-auto -mr-2 pr-2 space-y-3">
{allJurors.map((juror) => {
const isExpanded = expandedJurorId === juror.id
return (
<div key={juror.id}>
<button
type="button"
className="w-full text-left space-y-1 rounded-md px-1 -mx-1 py-1 hover:bg-muted/50 transition-colors"
onClick={() => setExpandedJurorId(isExpanded ? null : juror.id)}
>
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium" title={juror.name ?? ''}>
{juror.name ?? 'Unknown'}
</span>
<div className="ml-2 flex shrink-0 items-center gap-1.5">
<span className="text-xs tabular-nums text-muted-foreground">
{juror.completionRate}%
</span>
{isExpanded ? (
<ChevronUp className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</div>
</div>
<Progress value={juror.completionRate} className="h-1.5" />
<p className="text-[11px] text-muted-foreground">
{juror.completed} / {juror.assigned} evaluations
</p>
</button>
{isExpanded && juror.projects && (
<div className="ml-1 mt-1 space-y-1 border-l-2 border-muted pl-3">
{juror.projects.map((proj: { id: string; title: string; evalStatus: string }) => (
<Link
key={proj.id}
href={`/observer/projects/${proj.id}` as Route}
className="flex items-center justify-between gap-2 rounded py-1 text-xs hover:underline"
>
<span className="truncate">{proj.title}</span>
<StatusBadge status={proj.evalStatus} size="sm" />
</Link>
))}
</div>
)} )}
</div> </div>
) )
})}
</div>
) : (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-1.5 w-full" />
</div>
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Activity Feed */}
<AnimatedCard index={9}>
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Activity className="h-4 w-4 text-blue-500" />
</div>
Activity Feed
</CardTitle>
<CardDescription>Recent platform events</CardDescription>
</CardHeader>
<CardContent>
{activityFeed && activityFeed.length > 0 ? (
<div className="space-y-3">
{activityFeed
.filter((item) => !item.eventType.includes('transitioned') && !item.eventType.includes('transition'))
.slice(0, 5)
.map((item) => {
const iconDef = ACTIVITY_ICONS[item.eventType]
const IconComponent = iconDef?.icon ?? Activity
const iconColor = iconDef?.color ?? 'text-slate-400'
return (
<div key={item.id} className="flex items-start gap-3">
<IconComponent className={cn('mt-0.5 h-4 w-4 shrink-0', iconColor)} />
<p className="min-w-0 flex-1 text-sm leading-snug">
{humanizeActivity(item)}
</p>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{relativeTime(item.createdAt)}
</span>
</div>
)
})}
</div>
) : (
<div className="space-y-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-2 w-2 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3 w-12" />
</div>
))}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Full-width Map */}
<AnimatedCard index={11}>
{selectedProgramId ? (
<GeographicSummaryCard programId={selectedProgramId} />
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Globe className="h-5 w-5" />
Project Origins
</CardTitle>
<CardDescription>Geographic distribution of projects</CardDescription>
</CardHeader>
<CardContent>
<Skeleton className="h-[300px] w-full rounded-md" />
</CardContent>
</Card>
)}
</AnimatedCard>
</div>
)
} }

View File

@@ -0,0 +1,67 @@
'use client'
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import { trpc } from '@/lib/trpc/client'
type Program = {
id: string
name: string | null
year?: number
rounds?: Array<{ id: string; name: string; status: string; competitionId?: string }>
}
type EditionContextValue = {
programs: Program[]
selectedProgramId: string
setSelectedProgramId: (id: string) => void
activeRoundId: string
}
const EditionContext = createContext<EditionContextValue | null>(null)
export function useEditionContext() {
const ctx = useContext(EditionContext)
if (!ctx) throw new Error('useEditionContext must be used within EditionProvider')
return ctx
}
function findBestRound(rounds: Array<{ id: string; status: string }>): string {
const active = rounds.find(r => r.status === 'ROUND_ACTIVE')
if (active) return active.id
const closed = [...rounds].filter(r => r.status === 'ROUND_CLOSED').pop()
if (closed) return closed.id
return rounds[0]?.id ?? ''
}
export function EditionProvider({ children }: { children: ReactNode }) {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const { data: programs } = trpc.program.list.useQuery(
{ includeStages: true },
{ refetchInterval: 30_000 },
)
useEffect(() => {
if (programs && programs.length > 0 && !selectedProgramId) {
setSelectedProgramId(programs[0].id)
}
}, [programs, selectedProgramId])
const typedPrograms = (programs ?? []) as Program[]
const selectedProgram = typedPrograms.find(p => p.id === selectedProgramId)
const rounds = (selectedProgram?.rounds ?? []) as Array<{ id: string; status: string }>
const activeRoundId = findBestRound(rounds)
return (
<EditionContext.Provider
value={{
programs: typedPrograms,
selectedProgramId,
setSelectedProgramId,
activeRoundId,
}}
>
{children}
</EditionContext.Provider>
)
}

View File

@@ -0,0 +1,960 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { FileViewer } from '@/components/shared/file-viewer'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { UserAvatar } from '@/components/shared/user-avatar'
import { StatusBadge } from '@/components/shared/status-badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
AlertCircle,
Users,
FileText,
Calendar,
CheckCircle2,
Circle,
XCircle,
BarChart3,
ThumbsUp,
ThumbsDown,
MapPin,
Waves,
GraduationCap,
Heart,
Clock,
MessageSquare,
ArrowLeft,
} from 'lucide-react'
import { cn, formatDate, formatDateOnly } from '@/lib/utils'
export function ObserverProjectDetail({ projectId }: { projectId: string }) {
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
{ id: projectId },
{ refetchInterval: 30_000 },
)
const roundId = data?.assignments?.[0]?.roundId as string | undefined
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId: roundId ?? '' },
{ enabled: !!roundId },
)
if (isLoading) {
return <ProjectDetailSkeleton />
}
if (!data) {
return (
<div className="space-y-6">
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
<Link href={'/observer' as Route} className="hover:text-foreground">
Observer
</Link>
<span>/</span>
<Link
href={'/observer/projects' as Route}
className="hover:text-foreground"
>
Projects
</Link>
</nav>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Project Not Found</p>
<Button asChild className="mt-4">
<Link href={'/observer' as Route}>Back to Dashboard</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
const { project, assignments, stats, competitionRounds, projectRoundStates, filteringResult } =
data
const roundStateMap = new Map(
(projectRoundStates ?? []).map((s) => [s.roundId, s]),
)
const criteriaMap = new Map<
string,
{ label: string; type: string; trueLabel?: string; falseLabel?: string }
>()
if (activeForm?.criteriaJson) {
for (const c of activeForm.criteriaJson as Array<{
id: string
label: string
type?: string
trueLabel?: string
falseLabel?: string
}>) {
criteriaMap.set(c.id, {
label: c.label,
type: c.type || 'numeric',
trueLabel: c.trueLabel,
falseLabel: c.falseLabel,
})
}
}
// Compute per-criterion averages from all submitted evaluations
const criterionTotals = new Map<string, { sum: number; count: number }>()
for (const assignment of assignments) {
const ev = assignment.evaluation
if (ev?.status !== 'SUBMITTED') continue
const scores = (ev.criterionScoresJson || {}) as Record<
string,
number | boolean | string
>
for (const [key, value] of Object.entries(scores)) {
const meta = criteriaMap.get(key)
const type =
meta?.type ||
(typeof value === 'boolean'
? 'boolean'
: typeof value === 'string'
? 'text'
: 'numeric')
if (type !== 'numeric' && type !== 'section_header') continue
if (type === 'section_header') continue
if (typeof value !== 'number') continue
const existing = criterionTotals.get(key) || { sum: 0, count: 0 }
criterionTotals.set(key, {
sum: existing.sum + value,
count: existing.count + 1,
})
}
}
const criterionAverages = new Map<string, number>()
for (const [key, { sum, count }] of criterionTotals.entries()) {
if (count > 0) criterionAverages.set(key, sum / count)
}
return (
<div className="space-y-6">
{/* Back button */}
<Button variant="ghost" size="sm" className="gap-1.5 -ml-2 text-muted-foreground" asChild>
<Link href={'/observer/projects' as Route}>
<ArrowLeft className="h-3.5 w-3.5" />
Back to Projects
</Link>
</Button>
{/* Project Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
<ProjectLogoWithUrl project={project} size="lg" fallback="initials" />
<div className="min-w-0 space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
{project.teamName && (
<p className="text-muted-foreground">{project.teamName}</p>
)}
<div className="flex flex-wrap items-center gap-2">
{(project.country || project.geographicZone) && (
<Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" />
{project.country || project.geographicZone}
</Badge>
)}
{project.competitionCategory && (
<Badge variant="outline" className="gap-1">
<GraduationCap className="h-3 w-3" />
{project.competitionCategory === 'STARTUP'
? 'Start-up'
: 'Business Concept'}
</Badge>
)}
{project.oceanIssue && (
<Badge variant="outline" className="gap-1">
<Waves className="h-3 w-3" />
{project.oceanIssue.replace(/_/g, ' ')}
</Badge>
)}
<StatusBadge status={project.status ?? 'SUBMITTED'} />
</div>
</div>
</div>
{/* Score card */}
{stats && (
<Card className="w-full shrink-0 sm:w-48">
<CardContent className="pt-4">
<div className="flex flex-col items-center text-center">
<div className="rounded-lg bg-brand-teal/10 p-2">
<BarChart3 className="h-5 w-5 text-brand-teal" />
</div>
<p className="mt-2 text-4xl font-bold tabular-nums">
{stats.averageGlobalScore != null
? stats.averageGlobalScore.toFixed(1)
: '-'}
</p>
<p className="text-xs text-muted-foreground">
{stats.minScore ?? '-'} {stats.maxScore ?? '-'} range
</p>
<Separator className="my-2" />
<p className="text-xs text-muted-foreground">
{stats.totalEvaluations} evaluation
{stats.totalEvaluations !== 1 ? 's' : ''}
</p>
{stats.yesPercentage != null && (
<p className="mt-0.5 text-xs font-medium text-emerald-600">
{stats.yesPercentage.toFixed(0)}% recommended
</p>
)}
</div>
</CardContent>
</Card>
)}
</div>
<Separator />
{/* Tabs */}
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="evaluations">
Evaluations
{assignments.length > 0 && (
<Badge variant="secondary" className="ml-1.5 h-4 px-1 text-xs">
{assignments.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
</TabsList>
{/* ── Overview Tab ── */}
<TabsContent value="overview" className="mt-6 space-y-6">
{/* Criteria mini-cards */}
{criterionAverages.size > 0 && (
<AnimatedCard index={0}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<BarChart3 className="h-4 w-4 text-brand-teal" />
</div>
Criteria Averages
</CardTitle>
<CardDescription>
Averaged across all submitted evaluations
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{Array.from(criterionAverages.entries()).map(
([key, avg]) => {
const meta = criteriaMap.get(key)
const label = meta?.label || key
return (
<div
key={key}
className="rounded-lg border p-3 space-y-2"
>
<p className="text-xs font-medium text-muted-foreground line-clamp-2">
{label}
</p>
<p className="text-xl font-bold tabular-nums">
{avg.toFixed(1)}
</p>
<Progress
value={(avg / 10) * 100}
className="h-1.5"
/>
</div>
)
},
)}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* AI Rejection Reason */}
{project.status === 'REJECTED' && filteringResult?.aiScreeningJson && (() => {
const screening = filteringResult.aiScreeningJson as Record<string, Record<string, unknown>>
// Extract reasoning from the first rule's result
const firstRule = Object.values(screening)[0]
const reasoning = firstRule?.reasoning as string | undefined
const confidence = firstRule?.confidence as number | undefined
if (!reasoning) return null
return (
<AnimatedCard index={1}>
<Card className="border-red-200 bg-red-50/50">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2.5 text-lg text-red-700">
<div className="rounded-lg bg-red-100 p-1.5">
<AlertCircle className="h-4 w-4 text-red-600" />
</div>
AI Screening Rejected
{filteringResult.round && (
<span className="text-sm font-normal text-red-500 ml-auto">
at {filteringResult.round.name}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-red-800 whitespace-pre-wrap">{reasoning}</p>
{confidence != null && (
<p className="mt-2 text-xs text-red-500">
AI Confidence: {Math.round(confidence * 100)}%
</p>
)}
{filteringResult.overrideReason && (
<div className="mt-3 border-t border-red-200 pt-3">
<p className="text-xs font-medium text-red-600">Override Reason</p>
<p className="text-sm text-red-800">{filteringResult.overrideReason}</p>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
)
})()}
{/* Project Info — matches admin layout */}
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FileText className="h-4 w-4 text-emerald-500" />
</div>
Project Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
<div className="flex flex-wrap gap-2">
{project.competitionCategory && (
<Badge variant="outline" className="gap-1">
<GraduationCap className="h-3 w-3" />
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
</Badge>
)}
{project.oceanIssue && (
<Badge variant="outline" className="gap-1">
<Waves className="h-3 w-3" />
{project.oceanIssue.replace(/_/g, ' ')}
</Badge>
)}
{project.wantsMentorship && (
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
<Heart className="h-3 w-3" />
Wants Mentorship
</Badge>
)}
</div>
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</div>
)}
{/* Location, Institution, Founded */}
<div className="grid gap-4 sm:grid-cols-2">
{(project.country || project.geographicZone) && (
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone || project.country}</p>
</div>
</div>
)}
{project.institution && (
<div className="flex items-start gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Institution</p>
<p className="text-sm">{project.institution}</p>
</div>
</div>
)}
{project.foundedAt && (
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Founded</p>
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
</div>
</div>
)}
</div>
{/* AI-Assigned Expertise Tags */}
{project.projectTags && project.projectTags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Expertise Tags</p>
<div className="flex flex-wrap gap-2">
{project.projectTags.map((pt) => (
<Badge
key={pt.tag.id}
variant="secondary"
className="flex items-center gap-1"
style={pt.tag.color ? { backgroundColor: `${pt.tag.color}20`, borderColor: pt.tag.color } : undefined}
>
{pt.tag.name}
{pt.confidence < 1 && (
<span className="text-xs opacity-60">
{Math.round(pt.confidence * 100)}%
</span>
)}
</Badge>
))}
</div>
</div>
)}
{/* Simple Tags (legacy) */}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary">{tag}</Badge>
))}
</div>
</div>
)}
{/* Internal Info */}
{(project.internalComments || project.applicationStatus || project.referralSource) && (
<div className="border-t pt-4 mt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Internal Notes</p>
<div className="grid gap-3 sm:grid-cols-2">
{project.applicationStatus && (
<div>
<p className="text-xs text-muted-foreground">Application Status</p>
<p className="text-sm">{project.applicationStatus}</p>
</div>
)}
{project.referralSource && (
<div>
<p className="text-xs text-muted-foreground">Referral Source</p>
<p className="text-sm">{project.referralSource}</p>
</div>
)}
</div>
{project.internalComments && (
<div className="mt-3">
<p className="text-xs text-muted-foreground">Comments</p>
<p className="text-sm whitespace-pre-wrap">{project.internalComments}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-6 text-sm pt-2">
<div>
<span className="text-muted-foreground">Created:</span>{' '}
{formatDateOnly(project.createdAt)}
</div>
<div>
<span className="text-muted-foreground">Updated:</span>{' '}
{formatDateOnly(project.updatedAt)}
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Round History */}
{competitionRounds.length > 0 && (() => {
// Find the furthest round index where the project is active or beyond
// Any round before this must have been passed
let furthestActiveIdx = -1
for (let i = competitionRounds.length - 1; i >= 0; i--) {
const s = roundStateMap.get(competitionRounds[i].id)
if (s && (s.state === 'IN_PROGRESS' || s.state === 'PASSED' || s.state === 'COMPLETED')) {
furthestActiveIdx = i
break
}
}
// Find the rejection round — either explicit REJECTED state or inferred
const isProjectRejected = project.status === 'REJECTED'
const explicitRejectedRound = competitionRounds.find((r) => {
const s = roundStateMap.get(r.id)
return s?.state === 'REJECTED'
})
// If project is globally rejected but no round has explicit REJECTED state,
// infer the rejection round as the furthest round the project reached
let inferredRejectionRoundId: string | null = null
if (isProjectRejected && !explicitRejectedRound) {
for (let i = competitionRounds.length - 1; i >= 0; i--) {
const s = roundStateMap.get(competitionRounds[i].id)
if (s) {
if (s.state === 'PASSED' || s.state === 'COMPLETED') {
if (i + 1 < competitionRounds.length) {
inferredRejectionRoundId = competitionRounds[i + 1].id
}
} else {
inferredRejectionRoundId = competitionRounds[i].id
}
break
}
}
}
const rejectedRound = explicitRejectedRound
?? (inferredRejectionRoundId
? competitionRounds.find((r) => r.id === inferredRejectionRoundId)
: null)
const rejectedRoundIdx = rejectedRound
? competitionRounds.findIndex((r) => r.id === rejectedRound.id)
: -1
// Compute effective states for all rounds
const effectiveStates = competitionRounds.map((round, idx) => {
const rawState = roundStateMap.get(round.id)?.state
const isRejectionRound = round.id === rejectedRound?.id
const isNotReached = rejectedRoundIdx >= 0 && idx > rejectedRoundIdx
if (isRejectionRound && !explicitRejectedRound) return 'REJECTED'
if (isNotReached) return 'NOT_REACHED'
// If this round is before the furthest active round and not already PASSED/COMPLETED,
// the project must have passed it to reach the later round
if (furthestActiveIdx > idx && (!rawState || rawState === 'PENDING')) return 'PASSED'
return rawState
})
const passedCount = effectiveStates.filter(
(s) => s === 'PASSED' || s === 'COMPLETED',
).length
return (
<AnimatedCard index={3}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Calendar className="h-4 w-4 text-violet-500" />
</div>
Round History
</CardTitle>
<CardDescription>
{rejectedRound
? `Rejected at ${rejectedRound.name}`
: `Passed ${passedCount} of ${competitionRounds.length} rounds`}
</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-4">
{competitionRounds.map((round, idx) => {
const effectiveState = effectiveStates[idx]
const roundAssignments = assignments.filter(
(a) => a.roundId === round.id,
)
let icon: React.ReactNode
let statusLabel: string | null = null
let labelClass = 'text-muted-foreground'
if (effectiveState === 'PASSED' || effectiveState === 'COMPLETED') {
icon = <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
statusLabel = 'Passed'
} else if (effectiveState === 'REJECTED') {
icon = <XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
statusLabel = 'Rejected at this round'
labelClass = 'text-red-600 font-medium'
} else if (effectiveState === 'IN_PROGRESS') {
icon = (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
</span>
</span>
)
statusLabel = 'Active'
} else if (effectiveState === 'NOT_REACHED') {
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/15" />
statusLabel = 'Not reached'
labelClass = 'text-muted-foreground/50 italic'
} else if (effectiveState === 'PENDING') {
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" />
statusLabel = 'Pending'
} else {
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/20" />
}
return (
<li key={round.id} className={cn(
'flex items-start gap-3',
effectiveState === 'NOT_REACHED' && 'opacity-50',
)}>
{icon}
<div className="min-w-0 flex-1">
<p className={cn(
'text-sm font-medium',
effectiveState === 'NOT_REACHED' && 'text-muted-foreground',
)}>{round.name}</p>
{statusLabel && (
<p className={cn('text-xs', labelClass)}>
{statusLabel}
</p>
)}
{roundAssignments.length > 0 && (
<p className="text-xs text-muted-foreground">
{roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations
</p>
)}
</div>
{effectiveState === 'IN_PROGRESS' && (
<Badge
variant="outline"
className="ml-auto shrink-0 border-blue-200 bg-blue-50 text-blue-600 text-xs"
>
Active
</Badge>
)}
{effectiveState === 'REJECTED' && (
<Badge
variant="destructive"
className="ml-auto shrink-0 text-xs"
>
Rejected
</Badge>
)}
</li>
)
})}
</ol>
</CardContent>
</Card>
</AnimatedCard>
)
})()}
</TabsContent>
{/* ── Evaluations Tab ── */}
<TabsContent value="evaluations" className="mt-6 space-y-4">
{assignments.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Users className="h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
{project.status === 'ASSIGNED'
? 'Awaiting jury evaluation — assigned and pending review'
: 'No jury assignments yet'}
</p>
</CardContent>
</Card>
) : (
assignments.map((assignment) => {
const ev = assignment.evaluation
const isSubmitted = ev?.status === 'SUBMITTED'
const criterionScores = (ev?.criterionScoresJson || {}) as Record<
string,
number | boolean | string
>
return (
<AnimatedCard key={assignment.id} index={0}>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<UserAvatar
user={assignment.user}
avatarUrl={assignment.user.avatarUrl}
size="md"
/>
<div>
<p className="text-sm font-semibold">
{assignment.user.name || 'Unnamed'}
</p>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant="outline"
className="text-xs whitespace-nowrap"
>
{assignment.round.name}
</Badge>
{isSubmitted && ev.submittedAt && (
<span className="text-xs text-muted-foreground">
{formatDate(ev.submittedAt)}
</span>
)}
</div>
</div>
</div>
{isSubmitted && ev.globalScore != null && (
<div className="flex items-center gap-2">
<Badge className="bg-brand-teal text-white text-base px-3 py-1 font-bold">
{ev.globalScore}/10
</Badge>
</div>
)}
</div>
</CardHeader>
{isSubmitted && ev ? (
<CardContent className="space-y-4">
{/* Criterion scores */}
{Object.keys(criterionScores).length > 0 && (
<div className="space-y-2.5">
{Object.entries(criterionScores).map(
([key, value]) => {
const meta = criteriaMap.get(key)
const label = meta?.label || key
const type =
meta?.type ||
(typeof value === 'boolean'
? 'boolean'
: typeof value === 'string'
? 'text'
: 'numeric')
if (type === 'section_header') return null
if (type === 'boolean') {
return (
<div
key={key}
className="flex items-center justify-between rounded-lg border p-2.5"
>
<span className="text-sm">{label}</span>
{value === true ? (
<Badge
className="border-emerald-200 bg-emerald-100 text-emerald-700"
variant="outline"
>
<ThumbsUp className="mr-1 h-3 w-3" />
{meta?.trueLabel || 'Yes'}
</Badge>
) : (
<Badge
className="border-red-200 bg-red-100 text-red-700"
variant="outline"
>
<ThumbsDown className="mr-1 h-3 w-3" />
{meta?.falseLabel || 'No'}
</Badge>
)}
</div>
)
}
if (type === 'text') {
return (
<div key={key} className="space-y-1">
<p className="text-sm font-medium">
{label}
</p>
<div className="whitespace-pre-wrap rounded-lg border bg-muted/50 p-2.5 text-sm text-muted-foreground">
{typeof value === 'string'
? value
: String(value)}
</div>
</div>
)
}
// Numeric
return (
<div
key={key}
className="flex items-center gap-3 rounded-lg border p-2.5"
>
<span className="flex-1 truncate text-sm">
{label}
</span>
<div className="flex shrink-0 items-center gap-2">
<Progress
value={
typeof value === 'number'
? (value / 10) * 100
: 0
}
className="h-1.5 w-20"
/>
<span className="w-8 text-right text-sm font-bold tabular-nums">
{typeof value === 'number' ? value : '-'}
</span>
</div>
</div>
)
},
)}
</div>
)}
{/* Feedback */}
{ev.feedbackText && (
<div>
<p className="mb-1.5 flex items-center gap-1.5 text-sm font-medium">
<MessageSquare className="h-4 w-4" />
Feedback
</p>
<div className="whitespace-pre-wrap rounded-lg border bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
{ev.feedbackText}
</div>
</div>
)}
{/* Binary decision */}
{ev.binaryDecision != null && (
<div className="flex items-center gap-2">
{ev.binaryDecision ? (
<div className="flex items-center gap-1.5 text-emerald-600 font-medium text-sm">
<ThumbsUp className="h-4 w-4" />
Recommended
</div>
) : (
<div className="flex items-center gap-1.5 text-red-600 font-medium text-sm">
<ThumbsDown className="h-4 w-4" />
Not recommended
</div>
)}
</div>
)}
</CardContent>
) : (
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
Evaluation pending
</div>
</CardContent>
)}
</Card>
</AnimatedCard>
)
})
)}
</TabsContent>
{/* ── Files Tab ── */}
<TabsContent value="files" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<FileText className="h-4 w-4 text-rose-500" />
</div>
Project Files
</CardTitle>
</CardHeader>
<CardContent>
{project.files && project.files.length > 0 ? (
<FileViewer
projectId={projectId}
files={project.files.map((f) => ({
id: f.id,
fileName: f.fileName,
fileType: f.fileType as
| 'EXEC_SUMMARY'
| 'PRESENTATION'
| 'VIDEO'
| 'OTHER'
| 'BUSINESS_PLAN'
| 'VIDEO_PITCH'
| 'SUPPORTING_DOC',
mimeType: f.mimeType,
size: f.size,
bucket: f.bucket,
objectKey: f.objectKey,
pageCount: f.pageCount,
textPreview: f.textPreview,
detectedLang: f.detectedLang,
langConfidence: f.langConfidence,
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
requirementId: f.requirementId,
requirement: f.requirement
? {
id: f.requirement.id,
name: f.requirement.name,
description: f.requirement.description,
isRequired: f.requirement.isRequired,
}
: null,
}))}
/>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FileText className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No files uploaded yet
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function ProjectDetailSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-2" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-2" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<Skeleton className="h-16 w-16 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-40" />
<div className="flex gap-2">
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
</div>
</div>
</div>
<Skeleton className="h-32 w-48 rounded-lg" />
</div>
<Skeleton className="h-px w-full" />
<div className="flex gap-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-16" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,490 @@
'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="NOT_REVIEWED">Not Reviewed</SelectItem>
<SelectItem value="UNDER_REVIEW">Under Review</SelectItem>
<SelectItem value="REVIEWED">Reviewed</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.observerStatus ?? project.status} />
</TableCell>
<TableCell>
{project.evaluationCount > 0 && 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.observerStatus ?? 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>
{project.evaluationCount > 0 && (
<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

@@ -0,0 +1,303 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Users, Trophy } from 'lucide-react'
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
interface DeliberationReportTabsProps {
roundId: string
programId: string
}
function sessionStatusBadge(status: string) {
switch (status) {
case 'DELIB_LOCKED':
return <Badge variant="default">Locked</Badge>
case 'VOTING':
return <Badge variant="secondary">Voting</Badge>
case 'TALLYING':
return <Badge className="bg-amber-100 text-amber-800 border-amber-200">Tallying</Badge>
case 'RUNOFF':
return <Badge className="bg-rose-100 text-rose-800 border-rose-200">Runoff</Badge>
case 'DELIB_OPEN':
return <Badge variant="outline">Open</Badge>
default:
return <Badge variant="outline">{status}</Badge>
}
}
function sessionModeBadge(mode: string) {
return <Badge variant="outline">{mode.charAt(0) + mode.slice(1).toLowerCase()}</Badge>
}
function SessionsTab({ roundId }: { roundId: string }) {
const { data: sessions, isLoading } =
trpc.analytics.getDeliberationSessions.useQuery({ roundId })
if (isLoading) return <Skeleton className="h-[350px]" />
if (!sessions?.length) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Users className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm font-medium text-muted-foreground">No deliberation sessions yet</p>
<p className="text-xs text-muted-foreground mt-1">
Sessions will appear here once created by administrators
</p>
</CardContent>
</Card>
)
}
return (
<>
{/* Desktop table */}
<div className="hidden md:block rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Category</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right tabular-nums">Participants</TableHead>
<TableHead className="text-right tabular-nums">Votes Cast</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => (
<TableRow key={session.id}>
<TableCell className="font-medium">
{session.category ?? <span className="text-muted-foreground italic">General</span>}
</TableCell>
<TableCell>{sessionModeBadge(session.mode)}</TableCell>
<TableCell>{sessionStatusBadge(session.status)}</TableCell>
<TableCell className="text-right tabular-nums">{session._count.participants}</TableCell>
<TableCell className="text-right tabular-nums">{session._count.votes}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Mobile card stack */}
<div className="space-y-3 md:hidden">
{sessions.map((session) => (
<Card key={session.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm">
{session.category ?? <span className="italic text-muted-foreground">General</span>}
</p>
{sessionStatusBadge(session.status)}
</div>
<div className="flex items-center gap-2">
{sessionModeBadge(session.mode)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<p className="text-muted-foreground">Participants</p>
<p className="font-medium tabular-nums">{session._count.participants}</p>
</div>
<div>
<p className="text-muted-foreground">Votes Cast</p>
<p className="font-medium tabular-nums">{session._count.votes}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</>
)
}
function ResultsTab({ roundId }: { roundId: string }) {
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const { data: sessions, isLoading: sessionsLoading } =
trpc.analytics.getDeliberationSessions.useQuery({ roundId })
const activeSessions = sessions?.filter((s) => s._count.votes > 0) ?? []
const activeSessionId = selectedSessionId ?? activeSessions[0]?.id ?? null
const { data: aggregate, isLoading: aggregateLoading } =
trpc.analytics.getDeliberationAggregate.useQuery(
{ sessionId: activeSessionId! },
{ enabled: !!activeSessionId }
)
if (sessionsLoading) return <Skeleton className="h-[400px]" />
if (!activeSessions.length) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">
No votes have been cast yet. Results will appear once deliberation is underway.
</p>
</CardContent>
</Card>
)
}
const currentSessionId = selectedSessionId ?? activeSessions[0]?.id
return (
<div className="space-y-4">
{/* Session selector if multiple */}
{activeSessions.length > 1 && (
<div className="flex items-center gap-3 flex-wrap">
{activeSessions.map((s) => (
<button
key={s.id}
onClick={() => setSelectedSessionId(s.id)}
className={
currentSessionId === s.id
? 'rounded-full px-3 py-1 text-sm font-medium bg-primary text-primary-foreground'
: 'rounded-full px-3 py-1 text-sm font-medium bg-muted text-muted-foreground hover:bg-muted/80 transition-colors'
}
>
{s.category ?? 'General'}
</button>
))}
</div>
)}
{aggregateLoading ? (
<Skeleton className="h-[300px]" />
) : aggregate ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Trophy className="h-4 w-4 text-amber-500" />
Ranking Results
</CardTitle>
<CardDescription>
{aggregate.rankings.length} project{aggregate.rankings.length !== 1 ? 's' : ''} ranked
{aggregate.hasTies && (
<span className="ml-1 text-amber-600 font-medium">· Ties detected</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
{/* Desktop table */}
<div className="hidden md:block rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 text-center">Rank</TableHead>
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead className="text-right tabular-nums">Score</TableHead>
<TableHead className="text-right tabular-nums">Votes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{aggregate.rankings.map((r, idx) => {
const isTied = aggregate.tiedProjectIds.includes(r.projectId)
return (
<TableRow key={r.projectId} className={isTied ? 'bg-amber-50/50' : undefined}>
<TableCell className="text-center font-bold tabular-nums text-lg">
{idx + 1}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{r.projectTitle}</span>
{isTied && (
<Badge className="bg-amber-100 text-amber-800 border-amber-200 text-[10px]">
Tie
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">{r.teamName}</TableCell>
<TableCell className="text-right tabular-nums">
{typeof r.score === 'number' ? r.score : '—'}
</TableCell>
<TableCell className="text-right tabular-nums">{r.voteCount}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Mobile list */}
<div className="space-y-2 md:hidden">
{aggregate.rankings.map((r, idx) => {
const isTied = aggregate.tiedProjectIds.includes(r.projectId)
return (
<div
key={r.projectId}
className={`flex items-center gap-3 rounded-md p-3 border${isTied ? ' bg-amber-50/50' : ''}`}
>
<span className="text-2xl font-bold tabular-nums w-8 text-center shrink-0">
{idx + 1}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm truncate">{r.projectTitle}</p>
{isTied && (
<Badge className="bg-amber-100 text-amber-800 border-amber-200 text-[10px]">
Tie
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{r.teamName}</p>
</div>
<p className="text-xs text-muted-foreground tabular-nums shrink-0">
{r.voteCount} votes
</p>
</div>
)
})}
</div>
</CardContent>
</Card>
) : null}
</div>
)
}
export function DeliberationReportTabs({ roundId }: DeliberationReportTabsProps) {
return (
<div className="space-y-6">
<RoundTypeStatsCards roundId={roundId} />
<Tabs defaultValue="sessions" className="space-y-6">
<TabsList>
<TabsTrigger value="sessions" className="gap-2">
<Users className="h-4 w-4" />
Sessions
</TabsTrigger>
<TabsTrigger value="results" className="gap-2">
<Trophy className="h-4 w-4" />
Results
</TabsTrigger>
</TabsList>
<TabsContent value="sessions">
<SessionsTab roundId={roundId} />
</TabsContent>
<TabsContent value="results">
<ResultsTab roundId={roundId} />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,790 @@
'use client'
import { useState, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import {
FileSpreadsheet,
BarChart3,
Users,
TrendingUp,
Download,
Clock,
ClipboardCheck,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
CriteriaScoresChart,
JurorConsistencyChart,
JurorScoreHeatmap,
} from '@/components/charts'
import { BarChart } from '@tremor/react'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
import { ExpandableJurorTable } from './expandable-juror-table'
const ROUND_TYPE_LABELS: Record<string, string> = {
INTAKE: 'Intake',
FILTERING: 'Filtering',
EVALUATION: 'Evaluation',
SUBMISSION: 'Submission',
MENTORING: 'Mentoring',
LIVE_FINAL: 'Live Final',
DELIBERATION: 'Deliberation',
}
type Stage = {
id: string
name: string
status: string
roundType: string
windowCloseAt: Date | null
_count: { projects: number; assignments: number; evaluations: number }
programId: string
programName: string
}
function roundStatusLabel(status: string): string {
if (status === 'ROUND_ACTIVE') return 'Active'
if (status === 'ROUND_CLOSED') return 'Closed'
if (status === 'ROUND_DRAFT') return 'Draft'
if (status === 'ROUND_ARCHIVED') return 'Archived'
return status
}
function roundStatusVariant(status: string): 'default' | 'secondary' | 'outline' {
if (status === 'ROUND_ACTIVE') return 'default'
if (status === 'ROUND_CLOSED') return 'secondary'
return 'outline'
}
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { roundId: value }
}
interface EvaluationReportTabsProps {
roundId: string
programId: string
stages: Stage[]
selectedValue: string | null
}
// ---- Progress sub-tab ----
function ProgressSubTab({
selectedValue,
stages,
stagesLoading,
selectedRound,
}: {
selectedValue: string | null
stages: Stage[]
stagesLoading: boolean
selectedRound: Stage | undefined
}) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: overviewStats, isLoading: statsLoading } =
trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection })
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(queryInput, { enabled: hasSelection })
const [csvOpen, setCsvOpen] = useState(false)
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
const [csvLoading, setCsvLoading] = useState(false)
const handleRequestCsvData = useCallback(async () => {
setCsvLoading(true)
const columns = ['roundName', 'roundType', 'status', 'projects', 'assignments', 'completionRate']
const data = stages.map((s) => {
const assigned = s._count.assignments
const projects = s._count.projects
const rate = assigned > 0 && projects > 0 ? Math.round((assigned / projects) * 100) : 0
return {
roundName: s.name,
roundType: ROUND_TYPE_LABELS[s.roundType] || s.roundType,
status: roundStatusLabel(s.status),
projects,
assignments: assigned,
completionRate: rate,
}
})
const result = { data, columns }
setCsvData(result)
setCsvLoading(false)
return result
}, [stages])
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-base font-semibold">Progress Overview</h2>
<p className="text-sm text-muted-foreground">Evaluation progress across rounds</p>
</div>
<div className="flex items-center gap-2">
{selectedValue && !selectedValue.startsWith('all:') && (
<ExportPdfButton
roundId={selectedValue}
roundName={selectedRound?.name}
programName={selectedRound?.programName}
/>
)}
<Button
variant="outline"
size="sm"
onClick={() => setCsvOpen(true)}
disabled={stagesLoading || stages.length === 0}
>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
</div>
<CsvExportDialog
open={csvOpen}
onOpenChange={setCsvOpen}
exportData={csvData}
isLoading={csvLoading}
filename="round-progress"
onRequestData={handleRequestCsvData}
/>
{/* Stats tiles */}
{hasSelection && (
<>
{statsLoading ? (
<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-3 w-24" />
</CardContent>
</Card>
))}
</div>
) : overviewStats ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Projects</p>
<p className="text-2xl font-bold mt-1">{overviewStats.projectCount}</p>
<p className="text-xs text-muted-foreground mt-1">In round</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<FileSpreadsheet className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-teal-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Assignments</p>
<p className="text-2xl font-bold mt-1">{overviewStats.assignmentCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{overviewStats.projectCount > 0
? `${(overviewStats.assignmentCount / overviewStats.projectCount).toFixed(1)} reviews/project`
: 'No projects'}
</p>
</div>
<div className="rounded-xl bg-teal-50 p-3">
<ClipboardCheck className="h-5 w-5 text-teal-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">{overviewStats.evaluationCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{overviewStats.assignmentCount > 0
? `${overviewStats.evaluationCount}/${overviewStats.assignmentCount} submitted`
: 'Submitted'}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<TrendingUp className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Completion</p>
<p className="text-2xl font-bold mt-1">{overviewStats.completionRate}%</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<BarChart3 className="h-5 w-5 text-violet-600" />
</div>
</div>
<Progress value={overviewStats.completionRate} className="mt-3 h-2" gradient />
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
) : null}
</>
)}
{/* Completion Timeline */}
{hasSelection && (
<>
{timelineLoading ? (
<Skeleton className="h-[320px]" />
) : timeline?.length ? (
<EvaluationTimelineChart data={timeline} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground text-sm">No evaluation timeline data available yet</p>
</CardContent>
</Card>
)}
</>
)}
{/* Round Breakdown Table - Desktop */}
{stagesLoading ? (
<Skeleton className="h-[300px]" />
) : (
<>
<Card className="hidden md:block">
<CardHeader>
<CardTitle>Round Breakdown</CardTitle>
<CardDescription>Progress overview for each round</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Projects</TableHead>
<TableHead className="text-right">Assignments</TableHead>
<TableHead className="min-w-[140px]">Completion</TableHead>
<TableHead className="text-right">Avg Days</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stages.map((stage) => {
const projects = stage._count.projects
const assignments = stage._count.assignments
const evaluations = stage._count.evaluations
const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED'
const rate = isClosed
? 100
: assignments > 0
? Math.min(100, Math.round((evaluations / assignments) * 100))
: 0
return (
<TableRow key={stage.id}>
<TableCell>
<div>
<p className="font-medium">{stage.name}</p>
{stage.windowCloseAt && (
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Clock className="h-3 w-3" />
{formatDateOnly(stage.windowCloseAt)}
</p>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{ROUND_TYPE_LABELS[stage.roundType] || stage.roundType}
</Badge>
</TableCell>
<TableCell>
<Badge variant={roundStatusVariant(stage.status)}>
{roundStatusLabel(stage.status)}
</Badge>
</TableCell>
<TableCell className="text-right">{projects}</TableCell>
<TableCell className="text-right">{assignments}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={rate} className="h-2 w-20" />
<span className="text-sm tabular-nums">{rate}%</span>
</div>
</TableCell>
<TableCell className="text-right text-muted-foreground">-</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Round Breakdown Cards - Mobile */}
<div className="space-y-3 md:hidden">
<h2 className="text-base font-semibold">Round Breakdown</h2>
{stages.map((stage) => {
const projects = stage._count.projects
const assignments = stage._count.assignments
const evaluations = stage._count.evaluations
const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED'
const rate = isClosed
? 100
: assignments > 0
? Math.min(100, Math.round((evaluations / assignments) * 100))
: 0
return (
<Card key={stage.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<p className="font-medium leading-tight">{stage.name}</p>
<Badge variant={roundStatusVariant(stage.status)} className="shrink-0">
{roundStatusLabel(stage.status)}
</Badge>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">
{ROUND_TYPE_LABELS[stage.roundType] || stage.roundType}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-muted-foreground text-xs">Projects</p>
<p className="font-medium">{projects}</p>
</div>
<div>
<p className="text-muted-foreground text-xs">Assignments</p>
<p className="font-medium">{assignments}</p>
</div>
</div>
<div>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground text-xs">Completion</span>
<span className="font-medium">{rate}%</span>
</div>
<Progress value={rate} className="h-2" />
</div>
{stage.windowCloseAt && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
Closes: {formatDateOnly(stage.windowCloseAt)}
</p>
)}
</CardContent>
</Card>
)
})}
</div>
</>
)}
</div>
)
}
// ---- Jurors sub-tab ----
function JurorsSubTab({ roundId, selectedValue }: { roundId: string; selectedValue: string | null }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: workload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(queryInput, { enabled: hasSelection })
const { data: consistency, isLoading: consistencyLoading } =
trpc.analytics.getJurorConsistency.useQuery(queryInput, { enabled: hasSelection })
const { data: heatmapData, isLoading: heatmapLoading } =
trpc.analytics.getJurorScoreMatrix.useQuery({ roundId }, { enabled: !!roundId })
const [csvOpen, setCsvOpen] = useState(false)
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
const [csvLoading, setCsvLoading] = useState(false)
type WorkloadItem = { id: string; name: string; assigned: number; completed: number; completionRate: number; projects: { id: string; title: string; evalStatus: string }[] }
type ConsistencyJuror = { userId: string; name: string; evaluationCount: number; averageScore: number; stddev: number; isOutlier: boolean }
const handleRequestCsvData = useCallback(async () => {
setCsvLoading(true)
const columns = ['name', 'assigned', 'completed', 'completionRate', 'avgScore', 'stddev', 'isOutlier']
const workloadMap = new Map<string, WorkloadItem>()
if (workload) {
for (const w of (workload as unknown as WorkloadItem[])) {
workloadMap.set(w.id, w)
}
}
const jurors = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] } | undefined)?.jurors ?? []
const data = jurors.map((j) => {
const w = workloadMap.get(j.userId)
return {
name: j.name,
assigned: w?.assigned ?? '-',
completed: w?.completed ?? '-',
completionRate: w ? `${w.completionRate}%` : '-',
avgScore: j.averageScore,
stddev: j.stddev,
isOutlier: j.isOutlier ? 'Yes' : 'No',
}
})
const result = { data, columns }
setCsvData(result)
setCsvLoading(false)
return result
}, [workload, consistency])
const isLoading = workloadLoading || consistencyLoading
type JurorRow = {
userId: string
name: string
assigned: number
completed: number
completionRate: number
averageScore: number
stddev: number
isOutlier: boolean
projects: { id: string; title: string; evalStatus: string }[]
}
const jurors: JurorRow[] = (() => {
if (!consistency) return []
const workloadMap = new Map<string, WorkloadItem>()
if (workload) {
for (const w of (workload as unknown as WorkloadItem[])) {
workloadMap.set(w.id, w)
}
}
const jurorList = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] }).jurors ?? []
return jurorList
.map((j) => {
const w = workloadMap.get(j.userId)
return {
userId: j.userId,
name: j.name,
assigned: w?.assigned ?? 0,
completed: w?.completed ?? 0,
completionRate: w?.completionRate ?? 0,
averageScore: j.averageScore,
stddev: j.stddev,
isOutlier: j.isOutlier,
projects: w?.projects ?? [],
}
})
.sort((a, b) => b.assigned - a.assigned)
})()
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-base font-semibold">Juror Performance</h2>
<p className="text-sm text-muted-foreground">Workload and scoring consistency per juror</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCsvOpen(true)}
disabled={!hasSelection || isLoading}
>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<CsvExportDialog
open={csvOpen}
onOpenChange={setCsvOpen}
exportData={csvData}
isLoading={csvLoading}
filename="juror-performance"
onRequestData={handleRequestCsvData}
/>
{/* Expandable Juror Table */}
{isLoading ? (
<Skeleton className="h-[400px]" />
) : jurors.length > 0 ? (
<ExpandableJurorTable jurors={jurors} />
) : hasSelection ? (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No juror data available for this selection</p>
</CardContent>
</Card>
) : null}
{/* Juror Score Heatmap */}
{heatmapLoading ? (
<Skeleton className="h-[400px]" />
) : heatmapData ? (
<JurorScoreHeatmap
jurors={heatmapData.jurors}
projects={heatmapData.projects}
cells={heatmapData.cells}
truncated={heatmapData.truncated}
totalProjects={heatmapData.totalProjects}
/>
) : null}
{/* Juror Consistency Chart */}
{consistencyLoading ? (
<Skeleton className="h-[400px]" />
) : consistency ? (
<JurorConsistencyChart
data={consistency as { overallAverage: number; jurors: Array<{ userId: string; name: string; evaluationCount: number; averageScore: number; stddev: number; deviationFromOverall: number; isOutlier: boolean }> }}
/>
) : null}
</div>
)
}
// ---- Scores sub-tab ----
function ScoresSubTab({ selectedValue, programId }: { selectedValue: string | null; programId: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(queryInput, { enabled: hasSelection })
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(queryInput, { enabled: hasSelection })
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
const geoProgramId = queryInput.programId || programId
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
{ programId: geoProgramId, roundId: queryInput.roundId },
{ enabled: !!geoProgramId }
)
const [csvOpen, setCsvOpen] = useState(false)
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
const [csvLoading, setCsvLoading] = useState(false)
type CriterionItem = { criterionName: string; averageScore: number; count: number }
const handleRequestCsvData = useCallback(async () => {
setCsvLoading(true)
const columns = ['criterionName', 'averageScore', 'count']
const data = ((criteriaScores as CriterionItem[] | undefined) ?? []).map((c) => ({
criterionName: c.criterionName,
averageScore: c.averageScore,
count: c.count,
}))
const result = { data, columns }
setCsvData(result)
setCsvLoading(false)
return result
}, [criteriaScores])
const countryChartData = (() => {
if (!geoData?.length) return []
const sorted = [...geoData].sort((a, b) => b.count - a.count)
return sorted.slice(0, 15).map((d) => {
let name = d.countryCode
try {
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
name = displayNames.of(d.countryCode.toUpperCase()) || d.countryCode
} catch { /* keep code */ }
return { country: name, Projects: d.count }
})
})()
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-base font-semibold">Scores & Analytics</h2>
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and geographic data</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCsvOpen(true)}
disabled={!hasSelection || criteriaLoading}
>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<CsvExportDialog
open={csvOpen}
onOpenChange={setCsvOpen}
exportData={csvData}
isLoading={csvLoading}
filename="scores-criteria"
onRequestData={handleRequestCsvData}
/>
{/* Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2">
{scoreLoading ? (
<Skeleton className="h-[350px]" />
) : scoreDistribution ? (
<ScoreDistributionChart
data={scoreDistribution.distribution ?? []}
averageScore={scoreDistribution.averageScore ?? 0}
totalScores={scoreDistribution.totalScores ?? 0}
/>
) : hasSelection ? (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No score data available yet</p>
</CardContent>
</Card>
) : null}
{statusLoading ? (
<Skeleton className="h-[350px]" />
) : statusBreakdown ? (
<StatusBreakdownChart data={statusBreakdown} />
) : hasSelection ? (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No status data available yet</p>
</CardContent>
</Card>
) : null}
</div>
{/* Criteria Breakdown */}
{criteriaLoading ? (
<Skeleton className="h-[350px]" />
) : criteriaScores?.length ? (
<CriteriaScoresChart data={criteriaScores} />
) : hasSelection ? (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No criteria score data available yet</p>
</CardContent>
</Card>
) : null}
{/* Country Distribution */}
{geoLoading ? (
<Skeleton className="h-[400px]" />
) : countryChartData.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Top Countries</span>
<span className="text-sm font-normal text-muted-foreground">
{geoData?.length ?? 0} countries represented
</span>
</CardTitle>
</CardHeader>
<CardContent>
<BarChart
data={countryChartData}
index="country"
categories={['Projects']}
colors={['blue']}
layout="vertical"
yAxisWidth={140}
showLegend={false}
className="h-[400px]"
/>
</CardContent>
</Card>
) : null}
</div>
)
}
// ---- Main component ----
export function EvaluationReportTabs({ roundId, programId, stages, selectedValue }: EvaluationReportTabsProps) {
const selectedRound = stages.find((s) => s.id === selectedValue)
const stagesLoading = false // stages passed from parent already loaded
return (
<div className="space-y-6">
<RoundTypeStatsCards roundId={roundId} />
<Tabs defaultValue="progress" className="space-y-6">
<TabsList>
<TabsTrigger value="progress" className="gap-2">
<TrendingUp className="h-4 w-4" />
Progress
</TabsTrigger>
<TabsTrigger value="jurors" className="gap-2">
<Users className="h-4 w-4" />
Jurors
</TabsTrigger>
<TabsTrigger value="scores" className="gap-2">
<BarChart3 className="h-4 w-4" />
Scores
</TabsTrigger>
</TabsList>
<TabsContent value="progress">
<ProgressSubTab
selectedValue={selectedValue}
stages={stages}
stagesLoading={stagesLoading}
selectedRound={selectedRound}
/>
</TabsContent>
<TabsContent value="jurors">
<JurorsSubTab roundId={roundId} selectedValue={selectedValue} />
</TabsContent>
<TabsContent value="scores">
<ScoresSubTab selectedValue={selectedValue} programId={programId} />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { scoreGradient } from '@/components/charts/chart-theme'
import { ProjectPreviewDialog } from './project-preview-dialog'
interface JurorRow {
userId: string
name: string
assigned: number
completed: number
completionRate: number
averageScore: number
stddev: number
isOutlier: boolean
projects: { id: string; title: string; evalStatus: string; score?: number | null }[]
}
interface ExpandableJurorTableProps {
jurors: JurorRow[]
}
function evalStatusBadge(status: string) {
switch (status) {
case 'REVIEWED':
return <Badge variant="default">Reviewed</Badge>
case 'UNDER_REVIEW':
return <Badge variant="secondary">Under Review</Badge>
default:
return <Badge variant="outline">Not Reviewed</Badge>
}
}
function ScorePill({ score }: { score: number }) {
const bg = scoreGradient(score)
const text = score >= 6 ? '#ffffff' : '#1a1a1a'
return (
<span
className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
style={{ backgroundColor: bg, color: text }}
>
{score.toFixed(1)}
</span>
)
}
export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) {
const [expanded, setExpanded] = useState<string | null>(null)
const [previewProjectId, setPreviewProjectId] = useState<string | null>(null)
function toggle(userId: string) {
setExpanded((prev) => (prev === userId ? null : userId))
}
function openPreview(projectId: string, e: React.MouseEvent) {
e.stopPropagation()
setPreviewProjectId(projectId)
}
if (jurors.length === 0) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No juror data available</p>
</CardContent>
</Card>
)
}
return (
<>
{/* Desktop table */}
<div className="hidden md:block rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Juror</TableHead>
<TableHead className="text-right tabular-nums">Assigned</TableHead>
<TableHead className="text-right tabular-nums">Completed</TableHead>
<TableHead>Rate</TableHead>
<TableHead className="text-right tabular-nums">Avg Score</TableHead>
<TableHead className="text-right tabular-nums">Std Dev</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-8" />
</TableRow>
</TableHeader>
<TableBody>
{jurors.map((j) => (
<>
<TableRow
key={j.userId}
className="cursor-pointer hover:bg-muted/50"
onClick={() => toggle(j.userId)}
>
<TableCell className="font-medium">{j.name}</TableCell>
<TableCell className="text-right tabular-nums">{j.assigned}</TableCell>
<TableCell className="text-right tabular-nums">{j.completed}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={j.completionRate} className="w-20 h-2" />
<span className="text-xs tabular-nums text-muted-foreground">
{j.completionRate.toFixed(0)}%
</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{j.averageScore > 0 ? j.averageScore.toFixed(2) : '—'}
</TableCell>
<TableCell className="text-right tabular-nums">
{j.stddev > 0 ? j.stddev.toFixed(2) : '—'}
</TableCell>
<TableCell>
{j.isOutlier ? (
<Badge variant="destructive">Outlier</Badge>
) : (
<Badge variant="outline">Normal</Badge>
)}
</TableCell>
<TableCell>
{expanded === j.userId ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
</TableRow>
{expanded === j.userId && (
<TableRow key={`${j.userId}-expanded`}>
<TableCell colSpan={8} className="bg-muted/30 p-0">
<div className="px-6 py-3">
{j.projects.length === 0 ? (
<p className="text-sm text-muted-foreground">No projects</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b">
<th className="pb-2 font-medium">Project</th>
<th className="pb-2 font-medium text-center">Score</th>
<th className="pb-2 font-medium text-right">Status</th>
</tr>
</thead>
<tbody>
{j.projects.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2 pr-4">
<button
className="text-primary hover:underline text-left"
onClick={(e) => openPreview(p.id, e)}
>
{p.title}
</button>
</td>
<td className="py-2 text-center">
{p.score != null ? (
<ScorePill score={p.score} />
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="py-2 text-right">{evalStatusBadge(p.evalStatus)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</TableCell>
</TableRow>
)}
</>
))}
</TableBody>
</Table>
</div>
{/* Mobile card stack */}
<div className="space-y-3 md:hidden">
{jurors.map((j) => (
<Card key={j.userId}>
<CardContent className="p-4">
<button
className="w-full text-left"
onClick={() => toggle(j.userId)}
>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{j.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{j.completed}/{j.assigned} completed
</p>
</div>
<div className="flex items-center gap-2">
{j.isOutlier && <Badge variant="destructive">Outlier</Badge>}
{expanded === j.userId ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
<div className="mt-2 flex items-center gap-2">
<Progress value={j.completionRate} className="flex-1 h-1.5" />
<span className="text-xs tabular-nums text-muted-foreground">
{j.completionRate.toFixed(0)}%
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">Avg Score: </span>
<span className="tabular-nums font-medium">
{j.averageScore > 0 ? j.averageScore.toFixed(2) : '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Std Dev: </span>
<span className="tabular-nums font-medium">
{j.stddev > 0 ? j.stddev.toFixed(2) : '—'}
</span>
</div>
</div>
</button>
{expanded === j.userId && j.projects.length > 0 && (
<div className="mt-3 pt-3 border-t space-y-2">
{j.projects.map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2">
<button
className="text-sm text-primary hover:underline truncate text-left"
onClick={(e) => openPreview(p.id, e)}
>
{p.title}
</button>
<div className="flex items-center gap-2 shrink-0">
{p.score != null && <ScorePill score={p.score} />}
{evalStatusBadge(p.evalStatus)}
</div>
</div>
))}
</div>
)}
{expanded === j.userId && j.projects.length === 0 && (
<p className="mt-3 pt-3 border-t text-sm text-muted-foreground">No projects</p>
)}
</CardContent>
</Card>
))}
</div>
{/* Project Preview Dialog */}
<ProjectPreviewDialog
projectId={previewProjectId}
open={!!previewProjectId}
onOpenChange={(open) => { if (!open) setPreviewProjectId(null) }}
/>
</>
)
}

Some files were not shown because too many files have changed in this diff Show More